1234567891011import { beforeEach, describe, expect, it } from "vitest";1213import {14 detectCardForm,15 detectLoginFields,16 findOTPDigitBoxGroup,17 findOtpSignalingGroup,18 isAnyCredentialForm,19 isEmailFirstForm,20 isLoginForm,21 isSignupForm,22 isTOTPField,23 isVisible,24} from "./detect";25import { makeFieldVisible, makeInput } from "./_setup";26272829303132333435beforeEach(() => {36 document.body.innerHTML = "";37});3839404142function form(): HTMLFormElement {43 const f = document.createElement("form");44 document.body.appendChild(f);45 return f;46}4748describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {49 50 51 it("T-DT01: text + password → detected as a fillable login form", () => {52 const f = form();53 const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);54 const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f);55 const fields = detectLoginFields();56 expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true);57 expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);58 59 expect(isAnyCredentialForm(fields)).toBe(true);60 61 expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);62 });6364 65 66 67 it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => {68 const f = form();69 makeInput({ type: "text", name: "username" }, f);70 makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);71 makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f);72 const fields = detectLoginFields();73 expect(isSignupForm(fields)).toBe(true);74 expect(isLoginForm(fields)).toBe(false);75 });7677 78 79 80 it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => {81 const f = form();82 makeInput({ type: "email", name: "email", autocomplete: "email" }, f);83 const btn = document.createElement("button");84 btn.type = "submit";85 btn.textContent = "Next";86 f.appendChild(btn);87 const fields = detectLoginFields();88 expect(isEmailFirstForm(fields)).toBe(true);89 });9091 92 93 it("T-DT06: search form → isLoginForm false", () => {94 const f = form();95 makeInput({ type: "search", name: "q", placeholder: "Search" }, f);96 const fields = detectLoginFields();97 expect(isLoginForm(fields)).toBe(false);98 expect(isSignupForm(fields)).toBe(false);99 });100});101102describe("T-DT04: TOTP field detection", () => {103 104 105 it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => {106 const f = form();107 const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);108 expect(isTOTPField(otp)).toBe(true);109 });110111 it("T-DT04b: a plain password field is not a TOTP field", () => {112 const f = form();113 const pw = makeInput({ type: "password", name: "password" }, f);114 expect(isTOTPField(pw)).toBe(false);115 });116});117118describe("T-DT05: credit-card form detection", () => {119 120 121 it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => {122 const f = form();123 const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f);124 const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f);125 const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f);126 const card = detectCardForm();127 expect(card).not.toBeNull();128 expect(card!.number).toBe(number);129 expect(card!.expiryCombo).toBe(exp);130 expect(card!.cvc).toBe(cvc);131 });132133 it("T-DT05b: a login form is not a card form", () => {134 const f = form();135 makeInput({ type: "text", name: "username" }, f);136 makeInput({ type: "password", name: "password" }, f);137 expect(detectCardForm()).toBeNull();138 });139});140141describe("T-DT07–T-DT09: isVisible branches", () => {142 143 144 145146 147 it("T-DT07: zero-size rect → isVisible false", () => {148 const el = makeInput({ type: "text" });149 Object.defineProperty(el, "getBoundingClientRect", {150 configurable: true,151 value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),152 });153 expect(isVisible(el)).toBe(false);154 });155156 157 it("T-DT08: display:none → isVisible false", () => {158 const el = makeInput({ type: "text" });159 el.style.display = "none";160 expect(isVisible(el)).toBe(false);161 });162163 164 it("T-DT09: opacity:0 → isVisible false", () => {165 const el = makeInput({ type: "text" });166 el.style.opacity = "0";167 expect(isVisible(el)).toBe(false);168 });169170 it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => {171 const el = makeInput({ type: "text" });172 expect(isVisible(el)).toBe(true);173 });174});175176describe("T-DT10: deep (shadow DOM) detection", () => {177 178 179 180 it("T-DT10: login fields inside an open shadow root are detected", () => {181 const host = document.createElement("div");182 document.body.appendChild(host);183 const root = host.attachShadow({ mode: "open" });184 185 186 187 const wrapper = document.createElement("div");188 root.appendChild(wrapper);189 const user = document.createElement("input");190 user.type = "text";191 user.setAttribute("name", "username");192 user.setAttribute("autocomplete", "username");193 const pass = document.createElement("input");194 pass.type = "password";195 pass.setAttribute("autocomplete", "current-password");196 wrapper.append(user, pass);197 makeFieldVisible(user);198 makeFieldVisible(pass);199200 const fields = detectLoginFields();201 expect(fields.some((f) => f.type === "password")).toBe(true);202 expect(isAnyCredentialForm(fields)).toBe(true);203 });204});205206describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {207 208 209 210 211 212 213 214 215 216 217 it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => {218 const f = form();219 makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f);220 makeInput({ type: "password", autocomplete: "current-password" }, f);221 let fields: ReturnType<typeof detectLoginFields> = [];222 expect(() => { fields = detectLoginFields(); }).not.toThrow();223 expect(fields.some((x) => x.type === "password")).toBe(true);224 });225226 it("T-DT12: id=\"it's-me\" does not abort detection", () => {227 const f = form();228 makeInput({ type: "text", id: "it's-me", name: "identifier" }, f);229 makeInput({ type: "password", autocomplete: "current-password" }, f);230 expect(() => detectLoginFields()).not.toThrow();231 });232233 234 235 236 it("T-DT12b: id with an embedded double-quote does not abort detection", () => {237 const f = form();238 makeInput({ type: "text", id: 'a"quote', name: "weird" }, f);239 makeInput({ type: "password", autocomplete: "current-password" }, f);240 let fields: ReturnType<typeof detectLoginFields> = [];241 expect(() => { fields = detectLoginFields(); }).not.toThrow();242 expect(fields.some((x) => x.type === "password")).toBe(true);243 });244});245246describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {247 248 249 it("T-DT13: label[for] resolves the field as username", () => {250 const f = form();251 const input = makeInput({ type: "text", id: "login_user" }, f);252 const label = document.createElement("label");253 label.setAttribute("for", "login_user");254 label.textContent = "Username";255 256 257 f.appendChild(label);258 makeInput({ type: "password", autocomplete: "current-password" }, f);259 const fields = detectLoginFields();260 expect(fields.find((x) => x.el === input)?.type).toBe("username");261 });262263 264 it("T-DT14: placeholder='Username' → classified username", () => {265 const f = form();266 const input = makeInput({ type: "text", placeholder: "Username" }, f);267 makeInput({ type: "password", autocomplete: "current-password" }, f);268 const fields = detectLoginFields();269 expect(fields.find((x) => x.el === input)?.type).toBe("username");270 });271272 273 it("T-DT15: aria-label='Email address' → classified", () => {274 const f = form();275 const input = makeInput({ type: "text", "aria-label": "Email address" }, f);276 makeInput({ type: "password", autocomplete: "current-password" }, f);277 const fields = detectLoginFields();278 expect(fields.find((x) => x.el === input)?.type).toBe("username");279 });280});281282describe("T-DT16–T-DT17: edge form shapes", () => {283 284 285 it("T-DT16: form without a submit button is still a fillable login form", () => {286 const f = form();287 const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);288 const p = makeInput({ type: "password", autocomplete: "current-password" }, f);289 const fields = detectLoginFields();290 expect(fields.some((x) => x.el === u)).toBe(true);291 expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);292 expect(isAnyCredentialForm(fields)).toBe(true);293 expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);294 });295296 297 298 it("T-DT17: old + new + confirm passwords → signup-shaped, not login", () => {299 const f = form();300 makeInput({ type: "password", name: "old-password", autocomplete: "current-password" }, f);301 makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);302 makeInput({ type: "password", name: "confirm-password", autocomplete: "new-password" }, f);303 const fields = detectLoginFields();304 expect(isSignupForm(fields)).toBe(true);305 expect(isLoginForm(fields)).toBe(false);306 });307});308309310311312313describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {314 315 316 it("T-DT18: 6× maxlength=1 numeric inputs → group of 6 returned, first is box[0]", () => {317 const f = form();318 const boxes: HTMLInputElement[] = [];319 for (let i = 0; i < 6; i++) boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f));320 const group = findOTPDigitBoxGroup(boxes[2]!);321 expect(group).not.toBeNull();322 expect(group!.length).toBe(6);323 expect(group![0]).toBe(boxes[0]);324 });325326 327 328 329 it("T-DT19: a real one-time-code field classifies as TOTP; maxlength=1 boxes do not", () => {330 const f = form();331 const box = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);332 const real = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f);333 expect(isTOTPField(real)).toBe(true);334 expect(isTOTPField(box)).toBe(false);335 });336337 338 it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {339 const f = form();340 const boxes = [341 makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),342 makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),343 ];344 expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();345 });346});347348349350351352353354355describe("T-DT22–T-DT24: findOtpSignalingGroup (split-OTP boxes lacking maxlength)", () => {356 357 358 359 360 it("T-DT22: 6 one-time-code boxes (no maxlength) → group of 6, anchor is box[0]", () => {361 const f = form();362 const boxes: HTMLInputElement[] = [];363 for (let i = 0; i < 6; i++) {364 boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));365 }366 expect(isTOTPField(boxes[3]!)).toBe(true);367 const group = findOtpSignalingGroup(boxes[3]!);368 expect(group).not.toBeNull();369 expect(group!.length).toBe(6);370 expect(group![0]).toBe(boxes[0]);371 expect(group![5]).toBe(boxes[5]);372 });373374 375 376 377 it("T-DT23: a lone one-time-code field → null (not a group)", () => {378 const f = form();379 const lone = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);380 expect(findOtpSignalingGroup(lone)).toBeNull();381 });382383 384 385 it("T-DT24: only 3 one-time-code boxes → null (below 4-box floor)", () => {386 const f = form();387 const boxes: HTMLInputElement[] = [];388 for (let i = 0; i < 3; i++) {389 boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));390 }391 expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();392 });393394 395 396 397 398 it("T-DT25: a maxlength=1 box is not part of an OTP-signaling group", () => {399 const f = form();400 const boxes: HTMLInputElement[] = [];401 for (let i = 0; i < 6; i++) {402 boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric", autocomplete: "one-time-code" }, f));403 }404 expect(isTOTPField(boxes[0]!)).toBe(false);405 expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();406 expect(findOTPDigitBoxGroup(boxes[0]!)).not.toBeNull();407 });408});