diff --git a/extensions/extension/Makefile b/extensions/extension/Makefile index 89a44ae..2aa7e45 100644 --- a/extensions/extension/Makefile +++ b/extensions/extension/Makefile @@ -1,15 +1,22 @@ NAME := gnexus-creds-extension VERSION := 0.1.1 -SRC := manifest.json src icons lib +# Per-file prerequisites: a directory prerequisite (plain "src") never goes +# stale when only a file inside it is edited. +SRC := manifest.json $(shell find src icons lib -type f) BUILD := build DIST := dist # UI kit source: the version pinned by the frontend's package-lock.json. KIT := ../../frontend/node_modules/gnexus-ui-kit/dist -.PHONY: all clean lib chrome firefox +.PHONY: all clean test lib chrome firefox all: chrome firefox +# Detection logic is tested without a browser: plain node over field-detect.js. +# (Not chained to all/zip targets; run `make test` explicitly.) +test: + node tools/test-field-detect.js + # Materialize lib/ from the frontend's gnexus-ui-kit install. Runs manually # (fresh clones need it before build); it is not chained to `all:` because # a newer kit rebuilds zips with different content than the released ones. @@ -27,14 +34,14 @@ $(BUILD)/chrome: $(SRC) mkdir -p $@ - cp -r $(SRC) $@/ + cp --parents $(SRC) $@/ node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync('$@/manifest.json')); delete m.browser_specific_settings; fs.writeFileSync('$@/manifest.json', JSON.stringify(m,null,2));" sed -i 's|url("../assets/|url("assets/|g' $@/lib/gnexus-ui-kit.css sed -i "s|url('/assets/|url('assets/|g" $@/lib/gnexus-ui-kit.css $(BUILD)/firefox: $(SRC) mkdir -p $@ - cp -r $(SRC) $@/ + cp --parents $(SRC) $@/ # Firefox MV3 has no background.service_worker / type:"module": # bundle api.js + background.js into one classic script and use event-page scripts. node tools/bundle-background.js $@ diff --git a/extensions/extension/manifest.json b/extensions/extension/manifest.json index ff964ce..2238c5f 100644 --- a/extensions/extension/manifest.json +++ b/extensions/extension/manifest.json @@ -12,7 +12,7 @@ "content_scripts": [ { "matches": [""], - "js": ["src/content.js"], + "js": ["src/field-detect.js", "src/content.js"], "run_at": "document_idle" } ], diff --git a/extensions/extension/src/content.js b/extensions/extension/src/content.js index b13c4d6..1ddfe8f 100644 --- a/extensions/extension/src/content.js +++ b/extensions/extension/src/content.js @@ -47,68 +47,57 @@ } } -// --- Form detection --- +// --- Login-field detection --- +// DOM probing lives here; the scoring itself is pure string logic in +// field-detect.js (loaded before this file — manifest content_scripts order — +// and unit-tested with plain node via tools/test-field-detect.js). -function findLoginForms() { - const forms = []; - const inputs = document.querySelectorAll('input[type="password"]'); - for (const passwordInput of inputs) { - if (passwordInput.dataset.gnexusCreds === "handled") continue; - passwordInput.dataset.gnexusCreds = "handled"; - - const { form, usernameInput } = findFormContext(passwordInput); - forms.push({ form, usernameInput, passwordInput }); +function isVisible(input) { + try { + if (typeof input.checkVisibility === "function") { + return input.checkVisibility(); + } + } catch { + // fall through } - return forms; + return !!input.getClientRects().length; } -function findFormContext(passwordInput) { - const form = passwordInput.closest("form"); - if (form) { - return { form, usernameInput: findUsernameInput(form, passwordInput) }; +function makeFieldDescriptor(el, index) { + const labelText = []; + for (const label of el.labels || []) { + labelText.push(label.textContent || ""); } - // Fallback for SPA inputs without a
tag: - // look within a parent container (section/article/div) up to 3 levels up - let container = passwordInput.parentElement; - let depth = 0; - while (container && depth < 3) { - const user = findUsernameInput(container, passwordInput); - if (user) return { form: container, usernameInput: user }; - container = container.parentElement; - depth++; - } - return { form: null, usernameInput: null }; -} - -function findUsernameInput(scope, passwordInput) { - const candidates = scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'); - for (const input of candidates) { - if (input === passwordInput) continue; - const name = (input.name || "").toLowerCase(); - const id = (input.id || "").toLowerCase(); - const placeholder = (input.placeholder || "").toLowerCase(); - const autocomp = (input.autocomplete || "").toLowerCase(); - if ( - autocomp.includes("username") || - autocomp.includes("email") || - name.includes("user") || - name.includes("login") || - name.includes("email") || - id.includes("user") || - id.includes("login") || - id.includes("email") || - placeholder.includes("user") || - placeholder.includes("login") || - placeholder.includes("email") - ) { - return input; + const labelledBy = el.getAttribute("aria-labelledby"); + if (labelledBy) { + for (const id of labelledBy.split(/\s+/)) { + const node = id && document.getElementById(id); + if (node) labelText.push(node.textContent || ""); } } - // Fallback: first text/email input before the password - const all = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])')); - const idx = all.indexOf(passwordInput); - if (idx > 0) return all[idx - 1]; - return all[0] || null; + return { + el, + index, + type: (el.type || "").toLowerCase(), + name: (el.name || "").toLowerCase(), + id: (el.id || "").toLowerCase(), + placeholder: (el.getAttribute("placeholder") || "").toLowerCase(), + autocomplete: (el.getAttribute("autocomplete") || "").toLowerCase(), + ariaLabel: (el.getAttribute("aria-label") || "").toLowerCase(), + labelText: labelText.join(" ").toLowerCase(), + visible: isVisible(el), + disabled: !!el.disabled, + readonly: !!el.readOnly, + formEl: el.closest("form"), + }; +} + +function findLoginFields() { + const inputs = document.querySelectorAll( + 'input[type="password"], input[type="text"], input[type="email"], input[type="tel"], input[type="search"], input:not([type])' + ); + const fields = Array.from(inputs).map(makeFieldDescriptor); + return GnCredsDetect.findLoginTargets(fields); } // --- Autofill card --- @@ -331,14 +320,19 @@ async function scanPage() { await fetchSecretsForDomain(); - const forms = findLoginForms(); - for (const { form, usernameInput, passwordInput } of forms) { + // handled passwords are marked here — a password can gain its interceptor + // and card only once per page load + const targets = findLoginFields().filter( + ({ password }) => password.el.dataset.gnexusCreds !== "handled" + ); + for (const { password, username, context } of targets) { + password.el.dataset.gnexusCreds = "handled"; // Autofill card - if (secretsForDomain.length > 0 && usernameInput) { - await showAutofillCard(form, usernameInput, passwordInput, secretsForDomain); + if (secretsForDomain.length > 0 && username) { + await showAutofillCard(context, username.el, password.el, secretsForDomain); } // Save interceptor - attachSubmitInterceptor(form, usernameInput, passwordInput); + attachSubmitInterceptor(context, username?.el || null, password.el); } } diff --git a/extensions/extension/src/field-detect.js b/extensions/extension/src/field-detect.js new file mode 100644 index 0000000..7bbafed --- /dev/null +++ b/extensions/extension/src/field-detect.js @@ -0,0 +1,251 @@ +/** + * Login-field detection: pure scoring logic over "input descriptors". + * + * A descriptor is a plain object produced in content.js: + * { + * index, // scan order among all inputs + * el, // the actual (DOM object; only structure is used) + * type, name, id, placeholder, autocomplete, ariaLabel, labelText, + * visible, disabled, readonly, + * formEl // owning or null + * } + * + * Everything here is string/number logic — no DOM API access beyond `contains` + * — so tools/test-field-detect.js can exercise it with plain node. + * + * The haystack (name/id/placeholder/aria-label/label text) is normalized so + * that any non-alphanumeric run becomes "_": "E-mail address" -> "e_mail", + * "User Name" -> "user_name". Keywords then match on underscore boundaries. + */ +(function (root, factory) { + const api = factory(); + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } else { + root.GnCredsDetect = api; + } +})(typeof self !== "undefined" ? self : globalThis, function () { + "use strict"; + + // Input types allowed as username carriers. + const USERNAME_TYPES = new Set(["text", "", "email", "tel"]); + // Input types allowed as password carriers (besides type="password"). + const PASSWORD_TEXT_TYPES = new Set(["text", "", "tel"]); + + // [keyword, weight] — best hits give up to ~6; forms usually label the + // field 2-3 ways at once (name + label + placeholder), so one strong word + // is enough to beat noise. + const USERNAME_KEYWORDS = [ + ["username", 6], ["user_name", 6], ["user_id", 4], ["userid", 4], + ["login", 5], ["log_in", 3], ["logon", 3], ["signin", 2], + ["email", 5], ["e_mail", 5], ["mail", 3], ["correo", 4], ["courriel", 4], + ["phone", 4], ["tel", 2], ["telefon", 4], ["mobile", 2], + ["account", 2], ["nick", 3], ["handle", 2], ["member", 2], + ["benutzer", 5], ["benutzername", 6], ["kennung", 3], + ["логин", 6], ["пользователь", 5], ["юзер", 4], ["почта", 5], ["телефон", 4], + ["usuario", 5], ["utilisateur", 4], + ]; + + const PASSWORD_KEYWORDS = [ + ["password", 8], ["passwd", 8], ["pwd", 8], ["pass", 7], ["pw", 5], + ["passwort", 8], ["motdepasse", 8], ["mot_de_passe", 8], + ["contrasena", 8], ["contrasenia", 8],["senha", 8], ["haslo", 6], + ["geslo", 6], ["пароль", 8], ["пассворд", 6], + ]; + + // Word is present but the field is almost certainly not a login field. + // -8 per hit beats one medium keyword (+8/password or +6/username). + // The confirm/repeat group exists so that on a signup form the *real* + // password wins over "confirm_password": we only autofill existing + // accounts, so a repeat field should never become the fill target. + const ANTI_KEYWORDS = [ + "cc", "card", "cvc", "cvv", "security_code", "search", "query", "filter", + "coupon", "promo", "newsletter", "subscribe", "subject", "message", + "comment", "first_name", "lastname", "last_name", "fname", "lname", + "birth", "dob", "zip", "postal", "amount", "price", "total", "qty", + "country", "city", "street", "address", + "otp", "one_time", "onetime", "one_time_code", "verification", + "confirm", "repeat", "retype", "again", + ]; + + // Autocomplete attribute (normalized), exact token: [token, usernameKindWeight, passwordKindWeight] + const AUTOCOMPLETE_TOKENS = { + username: { username: 9, password: 0 }, + current_password: { username: 0, password: 9 }, + new_password: { username: 0, password: 7 }, + email: { username: 7, password: 0 }, + tel: { username: 3, password: 0 }, + }; + + function normalize(text) { + return String(text == null ? "" : text) + .toLowerCase() + .replace(/[^a-zа-яё0-9]+/g, "_"); + } + + // "(^|_)" — keyword boundaries survive normalization because separators + // became underscores and tokens keep their own letters. + function keywordRegex(keyword) { + return new RegExp("(?:^|_)" + keyword + "(?:_|$)"); + } + + // Sum of the two strongest distinct keyword hits in the field's haystack. + function keywordScore(haystack, keywords) { + const hits = []; + for (const [keyword, weight] of keywords) { + if (keywordRegex(keyword).test(haystack)) hits.push(weight); + } + hits.sort((a, b) => b - a); + return (hits[0] || 0) + (hits[1] || 0) * 0.5; + } + + function autocompletePenalty(ac) { + // one-time codes and card fields are never login credentials + if (ac === "one_time_code" || ac.startsWith("cc_")) return -1000; + return 0; + } + + function autocompleteBonus(ac, kind) { + const token = AUTOCOMPLETE_TOKENS[ac]; + return token ? token[kind] : 0; + } + + function scoreUsername(field) { + let score = 0; + switch (field.type) { + case "email": score += 5; break; + case "tel": score += 2; break; + case "text": case "": break; + case "search": score -= 4; break; + default: return -1000; + } + const ac = normalize(field.autocomplete); + const penalty = autocompletePenalty(ac); + if (penalty < 0) return penalty; + score += autocompleteBonus(ac, "username"); + score += keywordScore(normalize( + `${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}` + ), USERNAME_KEYWORDS); + for (const anti of ANTI_KEYWORDS) { + if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8; + // placeholder/labels mention search and coupons even on login pages, + // so anti-words only count in name/id + } + if (!field.visible) score -= 40; + if (field.disabled) score -= 100; + if (field.readonly) score -= 15; + return score; + } + + function scorePassword(field) { + let score = 0; + if (field.type === "password") score += 12; + else if (PASSWORD_TEXT_TYPES.has(field.type)) { /* text/tel with a password hint */ } + else return -1000; + const ac = normalize(field.autocomplete); + const penalty = autocompletePenalty(ac); + if (penalty < 0) return penalty; + score += autocompleteBonus(ac, "password"); + score += keywordScore(normalize( + `${field.name} ${field.id} ${field.placeholder} ${field.ariaLabel} ${field.labelText}` + ), PASSWORD_KEYWORDS); + for (const anti of ANTI_KEYWORDS) { + if (keywordRegex(anti).test(normalize(`${field.name} ${field.id}`))) score -= 8; + } + if (!field.visible) score -= 50; + if (field.disabled) score -= 100; + if (field.readonly) score -= 15; + return score; + } + + // Passwords qualify unconditionally on type="password" (the strongest + // signal there is) or, for text/tel fields, on a single strong keyword + // (e.g. type="text" with name="pass" — JS-revealed password inputs). + function isPasswordCandidate(field) { + if (field.type === "password") return scorePassword(field) > -1000; + if (!PASSWORD_TEXT_TYPES.has(field.type)) return false; + return scorePassword(field) >= 7; + } + + // The username is searched inside the form; for SPA inputs without a + // , walk up to three ancestor levels before giving up. + function findContextFor(field, fields) { + if (field.formEl) return field.formEl; + let current = field.el.parentElement; + for (let depth = 0; current && depth < 3; depth++) { + const hasUsername = fields.some( + (f) => f !== field && current.contains(f.el) && scoreUsername(f) > 0 + ); + if (hasUsername) return current; + current = current.parentElement; + } + return null; + } + + function pickUsername(candidates, password) { + const eligible = candidates.filter( + (f) => + f !== password && + f.usernameScore > 0 && + USERNAME_TYPES.has(f.type) + ); + if (eligible.length) { + // strongest signal wins; ties break toward DOM order (above password) + return eligible.sort( + (a, b) => b.usernameScore - a.usernameScore || a.index - b.index + )[0]; + } + // Form has no identifying hints: fall back to the closest text-like + // visible input preceding the password field. + const fallback = candidates + .filter( + (f) => + f !== password && + USERNAME_TYPES.has(f.type) && + f.visible && + !f.disabled && + f.index < password.index + ) + .sort((a, b) => b.index - a.index)[0]; + return fallback || null; + } + + function findLoginTargets(fields) { + const scored = fields.map((field) => ({ + ...field, + usernameScore: scoreUsername(field), + passwordScore: scorePassword(field), + })); + const candidates = scored + .filter((f) => isPasswordCandidate(f)) + .sort((a, b) => b.passwordScore - a.passwordScore || a.index - b.index); + + const usedContexts = new Set(); // one card per form + const usedUsernames = new Set(); + const targets = []; + + for (const password of candidates) { + const context = findContextFor(password, scored); + const contextKey = context || password.el; + if (usedContexts.has(contextKey)) continue; // merged into the first password of the form + usedContexts.add(contextKey); + + const inside = context ? scored.filter((f) => context.contains(f.el)) : [password]; + const username = pickUsername(inside.filter((f) => !usedUsernames.has(f.el)), password); + if (username) usedUsernames.add(username.el); + targets.push({ password, username: username || null, context: context || null }); + } + + // DOM order for stable card stacking + return targets.sort((a, b) => a.password.index - b.password.index); + } + + return { + scoreUsername, + scorePassword, + isPasswordCandidate, + findContextFor, + pickUsername, + findLoginTargets, + }; +}); \ No newline at end of file diff --git a/extensions/extension/tools/test-field-detect.js b/extensions/extension/tools/test-field-detect.js new file mode 100644 index 0000000..2fb1400 --- /dev/null +++ b/extensions/extension/tools/test-field-detect.js @@ -0,0 +1,234 @@ +#!/usr/bin/env node +/** + * Plain-node test suite for src/field-detect.js (no DOM, no deps). + * Usage: node tools/test-field-detect.js + */ +const assert = require("assert"); +const detect = require("../src/field-detect.js"); + +let passed = 0; +let failed = 0; + +function check(name, actual, expected) { + try { + assert.deepStrictEqual(actual, expected); + passed++; + } catch (e) { + failed++; + console.error(`FAIL ${name}\n expected: ${JSON.stringify(e.expected)}\n actual: ${JSON.stringify(e.actual)}`); + } +} + +// --- fake DOM --------------------------------------------------------------- +// Nodes form a tree via .parentElement; contains() walks the parent chain — enough +// for findContextFor/pickUsername, which only need element identity. +let nextNodeId; +function makeNode(spec = {}) { + const node = { id: spec.id || `n${nextNodeId++}`, parentElement: spec.parentElement || null }; + node.contains = (other) => { + if (other === node) return true; + let cur = other; + while (cur) { + cur = cur.parentElement; + if (cur === node) return true; + } + return false; + }; + return node; +} +function field(el, spec, index) { + return { + index, + el, + type: spec.type || "text", + name: spec.name || "", + id: spec.id || "", + placeholder: spec.placeholder || "", + autocomplete: spec.autocomplete || "", + ariaLabel: spec.ariaLabel || "", + labelText: spec.labelText || "", + visible: spec.visible !== false, + disabled: !!spec.disabled, + readonly: !!spec.readonly, + formEl: spec.formEl || null, + }; +} +const pairOf = (targets) => + targets.map((t) => [t.username ? t.username.el.id : null, t.password.el.id]); + +// --- 1. russian placeholders ------------------------------------------------- +{ + nextNodeId = 0; + const user = field(makeNode(), { name: "user", placeholder: "Логин или почта" }, 0); + const pass = field(makeNode(), { type: "password", placeholder: "Пароль" }, 1); + const root = makeNode(); + [user, pass].forEach((f) => (f.el.parentElement = root)); + check("russian placeholders pair", pairOf(detect.findLoginTargets([user, pass])), [["n0", "n1"]]); +} + +// --- 2. newsletter / search traps -------------------------------------------- +{ + nextNodeId = 0; + const newsletter = field(makeNode(), { name: "newsletter-email", labelText: "Subscribe to newsletter" }, 0); + const search = field(makeNode(), { name: "q", type: "search", ariaLabel: "Search site" }, 1); + const user = field(makeNode(), { name: "user", labelText: "Username" }, 2); + const pass = field(makeNode(), { type: "password", name: "pwd" }, 3); + const root = makeNode(); + [newsletter, search, user, pass].forEach((f) => (f.el.parentElement = root)); + check("traps rejected", pairOf(detect.findLoginTargets([newsletter, search, user, pass])), [["n2", "n3"]]); +} + +// --- 3. confirm-password pair merges into one target ------------------------- +{ + nextNodeId = 0; + const user = field(makeNode(), { type: "email", name: "email" }, 0); + const p1 = field(makeNode(), { type: "password", name: "password", autocomplete: "new-password" }, 1); + const p2 = field(makeNode(), { type: "password", name: "confirm_password", autocomplete: "new-password" }, 2); + const form = makeNode(); + for (const f of [user, p1, p2]) { + f.el.parentElement = form; + f.formEl = form; + } + const targets = detect.findLoginTargets([user, p1, p2]); + check("form with two passwords -> one target", targets.length, 1); + check("target username", targets[0].username.el.id, "n0"); +} + +// --- 4. SPA: no , username inside ancestor containers ------------------- +{ + nextNodeId = 0; + const user = field(makeNode(), { name: "login", ariaLabel: "Логин" }, 0); + const pass = field(makeNode(), { type: "password" }, 1); + const mid = makeNode(); + [user, pass].forEach((f) => (f.el.parentElement = mid)); + const outer = makeNode(); + mid.parentElement = outer; + const targets = detect.findLoginTargets([user, pass]); + check("SPA container fallback", pairOf(targets), [["n0", "n1"]]); +} + +// --- 5. OTP / cc fields are never passwords ----------------------------------- +{ + nextNodeId = 0; + const otp = field(makeNode(), { type: "text", name: "one-time-code", autocomplete: "one-time-code" }, 0); + const card = field(makeNode(), { type: "text", name: "cc-number", autocomplete: "cc-number" }, 1); + const pass = field(makeNode(), { type: "password", name: "password" }, 2); + const user = field(makeNode(), { placeholder: "Email", type: "email" }, 3); + const root = makeNode(); + [otp, card, pass, user].forEach((f) => (f.el.parentElement = root)); + check("otp/cc ignored", pairOf(detect.findLoginTargets([otp, card, pass, user])), [["n3", "n2"]]); +} + +// --- 6. type=text name=pass is a password ------------------------------------- +{ + nextNodeId = 0; + const user = field(makeNode(), { type: "email", name: "mail" }, 0); + const pass = field(makeNode(), { type: "text", name: "pass" }, 1); + const random = field(makeNode(), { name: "comment", labelText: "Comment" }, 2); + const root = makeNode(); + [user, pass, random].forEach((f) => (f.el.parentElement = root)); + check("text password classified", pairOf(detect.findLoginTargets([user, pass, random])), [["n0", "n1"]]); +} + +// --- 7. tel login ------------------------------------------------------------- +{ + nextNodeId = 0; + const phone = field(makeNode(), { type: "tel", placeholder: "Phone number" }, 0); + const pass = field(makeNode(), { type: "password", name: "password" }, 1); + const root = makeNode(); + [phone, pass].forEach((f) => (f.el.parentElement = root)); + check("tel username detected", pairOf(detect.findLoginTargets([phone, pass])), [["n0", "n1"]]); +} + +// --- 8. hidden / disabled candidates lose ------------------------------------- +{ + nextNodeId = 0; + const hiddenMail = field(makeNode(), { type: "email", name: "email", visible: false }, 0); + const user = field(makeNode(), { name: "user" }, 1); + const pass = field(makeNode(), { type: "password", name: "pass" }, 2); + const root = makeNode(); + [hiddenMail, user, pass].forEach((f) => { + f.el.parentElement = root; + f.formEl = root; + }); + check("hidden email loses", pairOf(detect.findLoginTargets([hiddenMail, user, pass])), [["n1", "n2"]]); + + const dis = field(makeNode(), { name: "email", disabled: true }, 3); + const user2 = field(makeNode(), { name: "username" }, 4); + const pass2 = field(makeNode(), { type: "password" }, 5); + const root2 = makeNode(); + [dis, user2, pass2].forEach((f) => { + f.el.parentElement = root2; + f.formEl = root2; + }); + check("disabled email loses", pairOf(detect.findLoginTargets([dis, user2, pass2])), [["n5", "n6"]]); +} + +// --- 9. autocomplete token pair ----------------------------------------------- +{ + nextNodeId = 0; + const user = field(makeNode(), { autocomplete: "username" }, 0); + const pass = field(makeNode(), { type: "password", autocomplete: "new-password" }, 1); + const root = makeNode(); + [user, pass].forEach((f) => (f.el.parentElement = root)); + check("autocomplete tokens", pairOf(detect.findLoginTargets([user, pass])), [["n0", "n1"]]); +} + +// --- 10. generic form -> closest preceding input fallback ---------------------- +{ + nextNodeId = 0; + const street = field(makeNode(), { name: "street" }, 0); + const street2 = field(makeNode(), { name: "street2", disabled: true }, 1); + const pass = field(makeNode(), { type: "password", name: "pass" }, 2); + const form = makeNode(); + for (const f of [street, street2, pass]) { + f.el.parentElement = form; + f.formEl = form; + } + check("closest preceding fallback", pairOf(detect.findLoginTargets([street, street2, pass])), [["n0", "n2"]]); +} + +// --- 11. label text alone identifies fields ------------------------------------ +{ + nextNodeId = 0; + const user = field(makeNode(), { labelText: "Work email" }, 0); + const pass = field(makeNode(), { type: "password", labelText: "Password" }, 1); + const root = makeNode(); + [user, pass].forEach((f) => (f.el.parentElement = root)); + check("label association", pairOf(detect.findLoginTargets([user, pass])), [["n0", "n1"]]); +} + +// --- 12. two independent forms on one page ------------------------------------- +{ + nextNodeId = 0; + const ua = field(makeNode(), { type: "email", name: "email" }, 0); + const pa = field(makeNode(), { type: "password", name: "password" }, 1); + const ub = field(makeNode(), { type: "email", name: "mail" }, 2); + const pb = field(makeNode(), { type: "password", name: "pass" }, 3); + const formA = makeNode(); + const formB = makeNode(); + ua.el.parentElement = pa.el.parentElement = formA; + ua.formEl = pa.formEl = formA; + ub.el.parentElement = pb.el.parentElement = formB; + ub.formEl = pb.formEl = formB; + const targets = detect.findLoginTargets([ua, pa, ub, pb]); + check("two forms -> two targets", targets.length, 2); + check("forms pair independently", pairOf(targets), [["n0", "n1"], ["n2", "n3"]]); +} + +// --- 13. real password wins over confirm_password (signup form) --------------- +{ + nextNodeId = 0; + const user = field(makeNode(), { name: "username" }, 0); + const pass = field(makeNode(), { type: "password", name: "password", placeholder: "Password" }, 1); + const confirm = field(makeNode(), { type: "password", name: "confirm_password", autocomplete: "new-password" }, 2); + const root = makeNode(); + for (const f of [user, pass, confirm]) { + f.el.parentElement = root; + f.formEl = root; + } + check("confirm field loses to password", pairOf(detect.findLoginTargets([user, pass, confirm])), [["n0", "n1"]]); +} + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); \ No newline at end of file