#!/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 <form>, 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);