123456789101112import { beforeEach, describe, expect, it } from "vitest";1314import {15 detectCardForm,16 detectLoginFields,17 findOTPDigitBoxGroup,18 findOtpSignalingGroup,19 isAnyCredentialForm,20 isEmailFirstForm,21 isLoginForm,22 isSignupForm,23 isTOTPField,24 isVisible,25} from "./detect";26import { makeFieldVisible, makeInput } from "./_setup";27282930313233343536beforeEach(() => {37 document.body.innerHTML = "";38});3940414243function form(): HTMLFormElement {44 const f = document.createElement("form");45 document.body.appendChild(f);46 return f;47}4849describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {50 51 52 it("T-DT01: text + password → detected as a fillable login form", () => {53 const f = form();54 const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);55 const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f);56 const fields = detectLoginFields();57 expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true);58 expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);59 60 expect(isAnyCredentialForm(fields)).toBe(true);61 62 expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);63 });6465 66 67 68 it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => {69 const f = form();70 makeInput({ type: "text", name: "username" }, f);71 makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);72 makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f);73 const fields = detectLoginFields();74 expect(isSignupForm(fields)).toBe(true);75 expect(isLoginForm(fields)).toBe(false);76 });7778 79 80 81 it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => {82 const f = form();83 makeInput({ type: "email", name: "email", autocomplete: "email" }, f);84 const btn = document.createElement("button");85 btn.type = "submit";86 btn.textContent = "Next";87 f.appendChild(btn);88 const fields = detectLoginFields();89 expect(isEmailFirstForm(fields)).toBe(true);90 });9192 93 94 it("T-DT06: search form → isLoginForm false", () => {95 const f = form();96 makeInput({ type: "search", name: "q", placeholder: "Search" }, f);97 const fields = detectLoginFields();98 expect(isLoginForm(fields)).toBe(false);99 expect(isSignupForm(fields)).toBe(false);100 });101});102103describe("T-DT04: TOTP field detection", () => {104 105 106 it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => {107 const f = form();108 const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);109 expect(isTOTPField(otp)).toBe(true);110 });111112 it("T-DT04b: a plain password field is not a TOTP field", () => {113 const f = form();114 const pw = makeInput({ type: "password", name: "password" }, f);115 expect(isTOTPField(pw)).toBe(false);116 });117});118119describe("T-DT05: credit-card form detection", () => {120 121 122 it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => {123 const f = form();124 const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f);125 const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f);126 const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f);127 const card = detectCardForm();128 expect(card).not.toBeNull();129 expect(card!.number).toBe(number);130 expect(card!.expiryCombo).toBe(exp);131 expect(card!.cvc).toBe(cvc);132 });133134 it("T-DT05b: a login form is not a card form", () => {135 const f = form();136 makeInput({ type: "text", name: "username" }, f);137 makeInput({ type: "password", name: "password" }, f);138 expect(detectCardForm()).toBeNull();139 });140});141142describe("T-DT07–T-DT09: isVisible branches", () => {143 144 145 146147 148 it("T-DT07: zero-size rect → isVisible false", () => {149 const el = makeInput({ type: "text" });150 Object.defineProperty(el, "getBoundingClientRect", {151 configurable: true,152 value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),153 });154 expect(isVisible(el)).toBe(false);155 });156157 158 it("T-DT08: display:none → isVisible false", () => {159 const el = makeInput({ type: "text" });160 el.style.display = "none";161 expect(isVisible(el)).toBe(false);162 });163164 165 it("T-DT09: opacity:0 → isVisible false", () => {166 const el = makeInput({ type: "text" });167 el.style.opacity = "0";168 expect(isVisible(el)).toBe(false);169 });170171 it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => {172 const el = makeInput({ type: "text" });173 expect(isVisible(el)).toBe(true);174 });175});176177describe("T-DT10: deep (shadow DOM) detection", () => {178 179 180 181 it("T-DT10: login fields inside an open shadow root are detected", () => {182 const host = document.createElement("div");183 document.body.appendChild(host);184 const root = host.attachShadow({ mode: "open" });185 186 187 188 const wrapper = document.createElement("div");189 root.appendChild(wrapper);190 const user = document.createElement("input");191 user.type = "text";192 user.setAttribute("name", "username");193 user.setAttribute("autocomplete", "username");194 const pass = document.createElement("input");195 pass.type = "password";196 pass.setAttribute("autocomplete", "current-password");197 wrapper.append(user, pass);198 makeFieldVisible(user);199 makeFieldVisible(pass);200201 const fields = detectLoginFields();202 expect(fields.some((f) => f.type === "password")).toBe(true);203 expect(isAnyCredentialForm(fields)).toBe(true);204 });205});206207describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {208 209 210 211 212 213 214 215 216 217 218 it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => {219 const f = form();220 makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f);221 makeInput({ type: "password", autocomplete: "current-password" }, f);222 let fields: ReturnType<typeof detectLoginFields> = [];223 expect(() => { fields = detectLoginFields(); }).not.toThrow();224 expect(fields.some((x) => x.type === "password")).toBe(true);225 });226227 it("T-DT12: id=\"it's-me\" does not abort detection", () => {228 const f = form();229 makeInput({ type: "text", id: "it's-me", name: "identifier" }, f);230 makeInput({ type: "password", autocomplete: "current-password" }, f);231 expect(() => detectLoginFields()).not.toThrow();232 });233234 235 236 237 it("T-DT12b: id with an embedded double-quote does not abort detection", () => {238 const f = form();239 makeInput({ type: "text", id: 'a"quote', name: "weird" }, f);240 makeInput({ type: "password", autocomplete: "current-password" }, f);241 let fields: ReturnType<typeof detectLoginFields> = [];242 expect(() => { fields = detectLoginFields(); }).not.toThrow();243 expect(fields.some((x) => x.type === "password")).toBe(true);244 });245});246247describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {248 249 250 it("T-DT13: label[for] resolves the field as username", () => {251 const f = form();252 const input = makeInput({ type: "text", id: "login_user" }, f);253 const label = document.createElement("label");254 label.setAttribute("for", "login_user");255 label.textContent = "Username";256 257 258 f.appendChild(label);259 makeInput({ type: "password", autocomplete: "current-password" }, f);260 const fields = detectLoginFields();261 expect(fields.find((x) => x.el === input)?.type).toBe("username");262 });263264 265 it("T-DT14: placeholder='Username' → classified username", () => {266 const f = form();267 const input = makeInput({ type: "text", placeholder: "Username" }, f);268 makeInput({ type: "password", autocomplete: "current-password" }, f);269 const fields = detectLoginFields();270 expect(fields.find((x) => x.el === input)?.type).toBe("username");271 });272273 274 it("T-DT15: aria-label='Email address' → classified", () => {275 const f = form();276 const input = makeInput({ type: "text", "aria-label": "Email address" }, f);277 makeInput({ type: "password", autocomplete: "current-password" }, f);278 const fields = detectLoginFields();279 expect(fields.find((x) => x.el === input)?.type).toBe("username");280 });281});282283describe("T-DT16–T-DT17: edge form shapes", () => {284 285 286 it("T-DT16: form without a submit button is still a fillable login form", () => {287 const f = form();288 const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);289 const p = makeInput({ type: "password", autocomplete: "current-password" }, f);290 const fields = detectLoginFields();291 expect(fields.some((x) => x.el === u)).toBe(true);292 expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);293 expect(isAnyCredentialForm(fields)).toBe(true);294 expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);295 });296297 298 299 it("T-DT17: old + new + confirm passwords → signup-shaped, not login", () => {300 const f = form();301 makeInput({ type: "password", name: "old-password", autocomplete: "current-password" }, f);302 makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);303 makeInput({ type: "password", name: "confirm-password", autocomplete: "new-password" }, f);304 const fields = detectLoginFields();305 expect(isSignupForm(fields)).toBe(true);306 expect(isLoginForm(fields)).toBe(false);307 });308});309310311312313314describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {315 316 317 it("T-DT18: 6× maxlength=1 numeric inputs → group of 6 returned, first is box[0]", () => {318 const f = form();319 const boxes: HTMLInputElement[] = [];320 for (let i = 0; i < 6; i++) boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f));321 const group = findOTPDigitBoxGroup(boxes[2]!);322 expect(group).not.toBeNull();323 expect(group!.length).toBe(6);324 expect(group![0]).toBe(boxes[0]);325 });326327 328 329 330 it("T-DT19: a real one-time-code field classifies as TOTP; maxlength=1 boxes do not", () => {331 const f = form();332 const box = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);333 const real = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f);334 expect(isTOTPField(real)).toBe(true);335 expect(isTOTPField(box)).toBe(false);336 });337338 339 340 341 it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {342 const f = form();343 const boxes = [344 makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),345 makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),346 ];347 expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();348 });349});350351352353354355356357358describe("T-DT22–T-DT24: findOtpSignalingGroup (split-OTP boxes lacking maxlength)", () => {359 360 361 362 363 it("T-DT22: 6 one-time-code boxes (no maxlength) → group of 6, anchor is box[0]", () => {364 const f = form();365 const boxes: HTMLInputElement[] = [];366 for (let i = 0; i < 6; i++) {367 boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));368 }369 expect(isTOTPField(boxes[3]!)).toBe(true);370 const group = findOtpSignalingGroup(boxes[3]!);371 expect(group).not.toBeNull();372 expect(group!.length).toBe(6);373 expect(group![0]).toBe(boxes[0]);374 expect(group![5]).toBe(boxes[5]);375 });376377 378 379 380 it("T-DT23: a lone one-time-code field → null (not a group)", () => {381 const f = form();382 const lone = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);383 expect(findOtpSignalingGroup(lone)).toBeNull();384 });385386 387 388 it("T-DT24: only 3 one-time-code boxes → null (below 4-box floor)", () => {389 const f = form();390 const boxes: HTMLInputElement[] = [];391 for (let i = 0; i < 3; i++) {392 boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));393 }394 expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();395 });396397 398 399 400 401 it("T-DT25: a maxlength=1 box is not part of an OTP-signaling group", () => {402 const f = form();403 const boxes: HTMLInputElement[] = [];404 for (let i = 0; i < 6; i++) {405 boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric", autocomplete: "one-time-code" }, f));406 }407 expect(isTOTPField(boxes[0]!)).toBe(false);408 expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();409 expect(findOTPDigitBoxGroup(boxes[0]!)).not.toBeNull();410 });411});412413414415416417418import * as detectModule from "./detect";419const hasIsSplitOtpGroup =420 typeof (detectModule as unknown as { isSplitOtpGroup?: unknown }).isSplitOtpGroup === "function";421422describe.skipIf(!hasIsSplitOtpGroup)("Addendum 1 — proposed isSplitOtpGroup (requires detect.ts change)", () => {423 const isSplitOtpGroup = (detectModule as unknown as {424 isSplitOtpGroup: (f: HTMLInputElement) => boolean;425 }).isSplitOtpGroup;426427 it("T-DT18 (proposed): 6 adjacent numeric single-char inputs → true", () => {428 const f = form();429 let first: HTMLInputElement | null = null;430 for (let i = 0; i < 6; i++) {431 const b = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);432 first ??= b;433 }434 expect(isSplitOtpGroup(first!)).toBe(true);435 });436437 it("T-DT20 (proposed): 3 single-char ALPHA inputs (initials) → false", () => {438 const f = form();439 let first: HTMLInputElement | null = null;440 for (let i = 0; i < 3; i++) {441 const b = makeInput({ type: "text", maxlength: "1" }, f); 442 first ??= b;443 }444 expect(isSplitOtpGroup(first!)).toBe(false);445 });446447 it("T-DT21 (proposed): a 2-box group is below the 3+ threshold → false", () => {448 const f = form();449 const a = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);450 makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);451 expect(isSplitOtpGroup(a)).toBe(false);452 });453});