Newer
Older
gnexus-creds / extensions / extension / tools / test-field-detect.js
#!/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);