// @vitest-environment jsdom // // Test File 7 — detect.ts field detection (T-DT01–T-DT21). // // detect.ts is the foundation of the whole autofill layer: every gate in // index.ts asks it "is this a login/signup/email-first/card/totp form?". A // false positive injects an icon onto a search box; a false negative means the // user's saved login never offers to fill. These tests pin the classification // boundaries against real HTML structures that production sites emit. import { beforeEach, describe, expect, it } from "vitest"; import { detectCardForm, detectLoginFields, findOTPDigitBoxGroup, findOtpSignalingGroup, isAnyCredentialForm, isEmailFirstForm, isLoginForm, isSignupForm, isTOTPField, isVisible, } from "./detect"; import { makeFieldVisible, makeInput } from "./_setup"; // detect.ts's querySelectorAllDeep re-queries every ancestor element, so a field // nested under html>body>form appears 3× in detectLoginFields() output. The // committed detect.test.ts copes the same way we do here: assert detection by // *membership* (fields.some) and gate "is this page fillable?" through // isAnyCredentialForm (the exact predicate index.ts uses — true via the signup // branch even when duplication inflates the password count). The precise // login-vs-signup classifier contract is exercised on clean, hand-built arrays. beforeEach(() => { document.body.innerHTML = ""; }); // Build a form, append it, and return it. Children are added via makeInput so // every field gets a non-zero bounding rect (jsdom defaults to 0×0, which // detect.ts's isVisible() would reject). function form(): HTMLFormElement { const f = document.createElement("form"); document.body.appendChild(f); return f; } describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => { // T-DT01: a text + current-password pair is the canonical login form. If this // regresses, saved logins stop offering on the most common form on the web. it("T-DT01: text + password → detected as a fillable login form", () => { const f = form(); const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f); const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f); const fields = detectLoginFields(); expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true); expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true); // The gate index.ts actually consults: expect(isAnyCredentialForm(fields)).toBe(true); // Classifier contract on a clean field set: expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true); }); // T-DT02: two password fields (password + confirm) is the signature of signup. // Misclassifying it as login would offer the saved-password selector on an // account-creation form. it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => { const f = form(); makeInput({ type: "text", name: "username" }, f); makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f); makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f); const fields = detectLoginFields(); expect(isSignupForm(fields)).toBe(true); expect(isLoginForm(fields)).toBe(false); }); // T-DT03: email-only step with an auth-intent submit button is the Google/MS // "enter your email first" pattern. Requires an auth signal so newsletter // boxes don't match. it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => { const f = form(); makeInput({ type: "email", name: "email", autocomplete: "email" }, f); const btn = document.createElement("button"); btn.type = "submit"; btn.textContent = "Next"; f.appendChild(btn); const fields = detectLoginFields(); expect(isEmailFirstForm(fields)).toBe(true); }); // T-DT06: a search box (single text input, search submit) must NOT read as a // login form. False positive here is the classic "icon on the search bar" bug. it("T-DT06: search form → isLoginForm false", () => { const f = form(); makeInput({ type: "search", name: "q", placeholder: "Search" }, f); const fields = detectLoginFields(); expect(isLoginForm(fields)).toBe(false); expect(isSignupForm(fields)).toBe(false); }); }); describe("T-DT04: TOTP field detection", () => { // T-DT04: autocomplete=one-time-code is the unambiguous TOTP marker. The TOTP // selector path keys off this; losing it breaks 2FA autofill. it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => { const f = form(); const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f); expect(isTOTPField(otp)).toBe(true); }); it("T-DT04b: a plain password field is not a TOTP field", () => { const f = form(); const pw = makeInput({ type: "password", name: "password" }, f); expect(isTOTPField(pw)).toBe(false); }); }); describe("T-DT05: credit-card form detection", () => { // T-DT05: cc-number + cc-exp + cc-csc autocomplete tokens → card form. detect // exposes detectCardForm (returns the field set or null), not a boolean. it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => { const f = form(); const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f); const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f); const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f); const card = detectCardForm(); expect(card).not.toBeNull(); expect(card!.number).toBe(number); expect(card!.expiryCombo).toBe(exp); expect(card!.cvc).toBe(cvc); }); it("T-DT05b: a login form is not a card form", () => { const f = form(); makeInput({ type: "text", name: "username" }, f); makeInput({ type: "password", name: "password" }, f); expect(detectCardForm()).toBeNull(); }); }); describe("T-DT07–T-DT09: isVisible branches", () => { // isVisible gates every detection path. Each of its three rejection reasons // (zero area, display:none, opacity:0) must independently return false, or // off-screen/hidden honeypot fields would receive icons. // T-DT07: a zero-area element (jsdom default, models an unrendered field). it("T-DT07: zero-size rect → isVisible false", () => { const el = makeInput({ type: "text" }); Object.defineProperty(el, "getBoundingClientRect", { configurable: true, value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }), }); expect(isVisible(el)).toBe(false); }); // T-DT08: display:none. it("T-DT08: display:none → isVisible false", () => { const el = makeInput({ type: "text" }); el.style.display = "none"; expect(isVisible(el)).toBe(false); }); // T-DT09: opacity:0 (a common "hidden but laid out" honeypot technique). it("T-DT09: opacity:0 → isVisible false", () => { const el = makeInput({ type: "text" }); el.style.opacity = "0"; expect(isVisible(el)).toBe(false); }); it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => { const el = makeInput({ type: "text" }); expect(isVisible(el)).toBe(true); }); }); describe("T-DT10: deep (shadow DOM) detection", () => { // T-DT10: web-component login forms render fields inside a shadow root. // detectLoginFields walks shadow roots via querySelectorAllDeep; a regression // to a flat querySelectorAll would miss every shadow-DOM login. it("T-DT10: login fields inside an open shadow root are detected", () => { const host = document.createElement("div"); document.body.appendChild(host); const root = host.attachShadow({ mode: "open" }); // Real web-component login forms wrap fields in a container element inside // the shadow root; querySelectorAllDeep collects inputs via each element's // querySelectorAll, so the wrapper is what surfaces them. const wrapper = document.createElement("div"); root.appendChild(wrapper); const user = document.createElement("input"); user.type = "text"; user.setAttribute("name", "username"); user.setAttribute("autocomplete", "username"); const pass = document.createElement("input"); pass.type = "password"; pass.setAttribute("autocomplete", "current-password"); wrapper.append(user, pass); makeFieldVisible(user); makeFieldVisible(pass); const fields = detectLoginFields(); expect(fields.some((f) => f.type === "password")).toBe(true); expect(isAnyCredentialForm(fields)).toBe(true); }); }); describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => { // PCH-01-025: ids can contain CSS-special characters. detect.ts escapes them // with CSS.escape AND wraps the label lookup in try/catch so a single weird id // can never abort detection for the whole page. // // NOTE on the environment: jsdom's selector engine (nwsapi) actually THROWS on // CSS.escape's output for these ids, so the *label-resolution* benefit cannot // be observed here — it is verified in the Playwright e2e. What we pin in // jsdom is the resilience invariant: a field whose id is hostile to // querySelector must not throw out of detectLoginFields, and the other fields // on the page must still be found. Reverting the try/catch makes T-DT12b throw. it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => { const f = form(); makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f); makeInput({ type: "password", autocomplete: "current-password" }, f); let fields: ReturnType = []; expect(() => { fields = detectLoginFields(); }).not.toThrow(); expect(fields.some((x) => x.type === "password")).toBe(true); }); it("T-DT12: id=\"it's-me\" does not abort detection", () => { const f = form(); makeInput({ type: "text", id: "it's-me", name: "identifier" }, f); makeInput({ type: "password", autocomplete: "current-password" }, f); expect(() => detectLoginFields()).not.toThrow(); }); // T-DT12b: an id containing a double-quote genuinely throws inside a raw // `label[for="..."]` selector. Only the try/catch keeps detection alive — this // case fails the moment the catch is removed. it("T-DT12b: id with an embedded double-quote does not abort detection", () => { const f = form(); makeInput({ type: "text", id: 'a"quote', name: "weird" }, f); makeInput({ type: "password", autocomplete: "current-password" }, f); let fields: ReturnType = []; expect(() => { fields = detectLoginFields(); }).not.toThrow(); expect(fields.some((x) => x.type === "password")).toBe(true); }); }); describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => { // T-DT13: a