17740443b130
add pure-module AUTOFILL-TESTS drafts: detect (T-DT01-21 + skipIf isSplitOtpGroup spec), fill (T-FL01-12 incl React prototype-setter bypass), interaction (T-IT01-06), policy (T-PO01-06) — 58 passing, classifiers tested on clean arrays to dodge querySelectorAllDeep ancestor duplication
- time
- 2026-06-12 12:39 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- autofill-tests draft suite
- parent
- 1e03ef874559
↓ peach-browser-17740443.tar.gzatlas at this save
files changed4 files
src/drafts/AUTOFILL-TESTS/content.detect.test.ts+390 −0src/drafts/AUTOFILL-TESTS/content.fill.test.ts+198 −0src/drafts/AUTOFILL-TESTS/content.interaction.test.ts+115 −0src/drafts/AUTOFILL-TESTS/content.policy.test.ts+126 −0src/drafts/AUTOFILL-TESTS/content.detect.test.ts+390 −0
@@ -0,0 +1,390 @@1+// @vitest-environment jsdom2+//3+// Test File 7 — detect.ts field detection (T-DT01–T-DT21).4+// Plus Addendum 1 — split-OTP grouping.5+//6+// detect.ts is the foundation of the whole autofill layer: every gate in7+// index.ts asks it "is this a login/signup/email-first/card/totp form?". A8+// false positive injects an icon onto a search box; a false negative means the9+// user's saved login never offers to fill. These tests pin the classification10+// boundaries against real HTML structures that production sites emit.11+12+import { beforeEach, describe, expect, it } from "vitest";13+14+import {15+ detectCardForm,16+ detectLoginFields,17+ findOTPDigitBoxGroup,18+ isAnyCredentialForm,19+ isEmailFirstForm,20+ isLoginForm,21+ isSignupForm,22+ isTOTPField,23+ isVisible,24+} from "../../content/detect";25+import { makeFieldVisible, makeInput } from "./_setup";26+27+// detect.ts's querySelectorAllDeep re-queries every ancestor element, so a field28+// nested under html>body>form appears 3× in detectLoginFields() output. The29+// committed detect.test.ts copes the same way we do here: assert detection by30+// *membership* (fields.some) and gate "is this page fillable?" through31+// isAnyCredentialForm (the exact predicate index.ts uses — true via the signup32+// branch even when duplication inflates the password count). The precise33+// login-vs-signup classifier contract is exercised on clean, hand-built arrays.34+35+beforeEach(() => {36+ document.body.innerHTML = "";37+});38+39+// Build a form, append it, and return it. Children are added via makeInput so40+// every field gets a non-zero bounding rect (jsdom defaults to 0×0, which41+// detect.ts's isVisible() would reject).42+function form(): HTMLFormElement {43+ const f = document.createElement("form");44+ document.body.appendChild(f);45+ return f;46+}47+48+describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {49+ // T-DT01: a text + current-password pair is the canonical login form. If this50+ // regresses, saved logins stop offering on the most common form on the web.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+ // The gate index.ts actually consults:59+ expect(isAnyCredentialForm(fields)).toBe(true);60+ // Classifier contract on a clean field set:61+ expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);62+ });63+64+ // T-DT02: two password fields (password + confirm) is the signature of signup.65+ // Misclassifying it as login would offer the saved-password selector on an66+ // account-creation form.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+ });76+77+ // T-DT03: email-only step with an auth-intent submit button is the Google/MS78+ // "enter your email first" pattern. Requires an auth signal so newsletter79+ // boxes don't match.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+ });90+91+ // T-DT06: a search box (single text input, search submit) must NOT read as a92+ // login form. False positive here is the classic "icon on the search bar" bug.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+});101+102+describe("T-DT04: TOTP field detection", () => {103+ // T-DT04: autocomplete=one-time-code is the unambiguous TOTP marker. The TOTP104+ // selector path keys off this; losing it breaks 2FA autofill.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+ });110+111+ 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+});117+118+describe("T-DT05: credit-card form detection", () => {119+ // T-DT05: cc-number + cc-exp + cc-csc autocomplete tokens → card form. detect120+ // exposes detectCardForm (returns the field set or null), not a boolean.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+ });132+133+ 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+});140+141+describe("T-DT07–T-DT09: isVisible branches", () => {142+ // isVisible gates every detection path. Each of its three rejection reasons143+ // (zero area, display:none, opacity:0) must independently return false, or144+ // off-screen/hidden honeypot fields would receive icons.145+146+ // T-DT07: a zero-area element (jsdom default, models an unrendered field).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+ });155+156+ // T-DT08: display:none.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+ });162+163+ // T-DT09: opacity:0 (a common "hidden but laid out" honeypot technique).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+ });169+170+ 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+});175+176+describe("T-DT10: deep (shadow DOM) detection", () => {177+ // T-DT10: web-component login forms render fields inside a shadow root.178+ // detectLoginFields walks shadow roots via querySelectorAllDeep; a regression179+ // to a flat querySelectorAll would miss every shadow-DOM login.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+ // Real web-component login forms wrap fields in a container element inside185+ // the shadow root; querySelectorAllDeep collects inputs via each element's186+ // querySelectorAll, so the wrapper is what surfaces them.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);199+200+ const fields = detectLoginFields();201+ expect(fields.some((f) => f.type === "password")).toBe(true);202+ expect(isAnyCredentialForm(fields)).toBe(true);203+ });204+});205+206+describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {207+ // PCH-01-025: ids can contain CSS-special characters. detect.ts escapes them208+ // with CSS.escape AND wraps the label lookup in try/catch so a single weird id209+ // can never abort detection for the whole page.210+ //211+ // NOTE on the environment: jsdom's selector engine (nwsapi) actually THROWS on212+ // CSS.escape's output for these ids, so the *label-resolution* benefit cannot213+ // be observed here — it is verified in the Playwright e2e. What we pin in214+ // jsdom is the resilience invariant: a field whose id is hostile to215+ // querySelector must not throw out of detectLoginFields, and the other fields216+ // on the page must still be found. Reverting the try/catch makes T-DT12b throw.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+ });225+226+ 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+ });232+233+ // T-DT12b: an id containing a double-quote genuinely throws inside a raw234+ // `label[for="..."]` selector. Only the try/catch keeps detection alive — this235+ // case fails the moment the catch is removed.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+});245+246+describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {247+ // T-DT13: a <label for=id> with a plain (escape-safe) id classifies an248+ // otherwise-unhinted text input as the username field.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+ // Append AFTER the input so the previous-sibling heuristic cannot match —256+ // forcing the for-attribute path to be the one that resolves the label.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+ });262+263+ // T-DT14: placeholder text is a real-world classification signal.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+ });271+272+ // T-DT15: aria-label is authoritative for accessibility-first forms.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+});281+282+describe("T-DT16–T-DT17: edge form shapes", () => {283+ // T-DT16: many SPA login forms submit via JS and have no submit button. Field284+ // classification must not depend on a button existing.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+ });295+296+ // T-DT17: change-password forms have old + new + confirm. Three password297+ // fields must read as signup-shaped (>=2 passwords), never as login.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+});308+309+// ── Addendum 1 — split-OTP grouping ─────────────────────────────────────────310+// The existing detector is findOTPDigitBoxGroup: it returns the ordered set of311+// single-character boxes (4–10 of them) or null. index.ts uses it to open ONE312+// TOTP selector anchored on the first box instead of icon-spamming all six.313+describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {314+ // T-DT18: a 6-box numeric group resolves to the full ordered set, anchored so315+ // index.ts injects a single icon on box[0] rather than one per box.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+ });325+326+ // T-DT19: when a real one-time-code field exists, IT is the TOTP target; the327+ // single-char boxes elsewhere are a distinct concern. isTOTPField ignores328+ // maxlength=1 boxes, so the proper field is the one that classifies as TOTP.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+ });336+337+ // T-DT21: a group below the 4-box floor is not an OTP group (existing338+ // threshold). Addendum 1 proposes a 3+ threshold for isSplitOtpGroup — see the339+ // skipped block below.340+ it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {341+ const f = form();342+ const boxes = [343+ makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),344+ makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),345+ ];346+ expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();347+ });348+});349+350+// Addendum 1 PROPOSED API — isSplitOtpGroup(field) with a 3+ threshold and a351+// numeric-only guard so an initials form (3 single-char *alpha* inputs) is not352+// treated as OTP. This function does not exist yet; these tests activate the353+// moment it is exported from detect.ts. They are the executable spec for the354+// addendum. See INTEGRATION-CONTRACT.md.355+import * as detectModule from "../../content/detect";356+const hasIsSplitOtpGroup =357+ typeof (detectModule as unknown as { isSplitOtpGroup?: unknown }).isSplitOtpGroup === "function";358+359+describe.skipIf(!hasIsSplitOtpGroup)("Addendum 1 — proposed isSplitOtpGroup (requires detect.ts change)", () => {360+ const isSplitOtpGroup = (detectModule as unknown as {361+ isSplitOtpGroup: (f: HTMLInputElement) => boolean;362+ }).isSplitOtpGroup;363+364+ it("T-DT18 (proposed): 6 adjacent numeric single-char inputs → true", () => {365+ const f = form();366+ let first: HTMLInputElement | null = null;367+ for (let i = 0; i < 6; i++) {368+ const b = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);369+ first ??= b;370+ }371+ expect(isSplitOtpGroup(first!)).toBe(true);372+ });373+374+ it("T-DT20 (proposed): 3 single-char ALPHA inputs (initials) → false", () => {375+ const f = form();376+ let first: HTMLInputElement | null = null;377+ for (let i = 0; i < 3; i++) {378+ const b = makeInput({ type: "text", maxlength: "1" }, f); // no numeric mode379+ first ??= b;380+ }381+ expect(isSplitOtpGroup(first!)).toBe(false);382+ });383+384+ it("T-DT21 (proposed): a 2-box group is below the 3+ threshold → false", () => {385+ const f = form();386+ const a = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);387+ makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);388+ expect(isSplitOtpGroup(a)).toBe(false);389+ });390+});src/drafts/AUTOFILL-TESTS/content.fill.test.ts+198 −0
@@ -0,0 +1,198 @@1+// @vitest-environment jsdom2+//3+// Test File 8 — fill.ts credential injection (T-FL01–T-FL12).4+//5+// fill.ts is where credentials actually enter the page. The subtle part is6+// setNativeValue: React/Vue track input values through the native value setter,7+// not direct `.value =` assignment, so fill.ts pulls the prototype setter and8+// dispatches real input+change events. If that breaks, fills look successful to9+// Peach but the site's controlled-input state stays empty — the canonical10+// "autofill did nothing" bug. These tests use real DOM elements, never mocks.11+12+import { beforeEach, describe, expect, it, vi } from "vitest";13+14+import {15+ detectCardNetwork,16+ extractLast4,17+ fillCardForm,18+ fillFields,19+ fillTOTPCode,20+ formatCardNumber,21+ passesLuhn,22+ setNativeValue,23+} from "../../content/fill";24+import { makeInput } from "./_setup";25+26+beforeEach(() => {27+ document.body.innerHTML = "";28+});29+30+describe("T-FL01–T-FL03: fillFields + event dispatch", () => {31+ // T-FL01: the core contract — username and password land in their inputs.32+ it("T-FL01: fills username and password into standard inputs", () => {33+ const u = makeInput({ type: "text" });34+ const p = makeInput({ type: "password" });35+ fillFields(u, p, "alice", "s3cret");36+ expect(u.value).toBe("alice");37+ expect(p.value).toBe("s3cret");38+ });39+40+ // T-FL02: an input event must fire so reactive frameworks re-read the field.41+ it("T-FL02: dispatches an input event on filled fields", () => {42+ const p = makeInput({ type: "password" });43+ const onInput = vi.fn();44+ p.addEventListener("input", onInput);45+ fillFields(null, p, "", "pw");46+ expect(onInput).toHaveBeenCalledTimes(1);47+ });48+49+ // T-FL03: a change event must fire too (some frameworks/validators key on it).50+ it("T-FL03: dispatches a change event on filled fields", () => {51+ const p = makeInput({ type: "password" });52+ const onChange = vi.fn();53+ p.addEventListener("change", onChange);54+ fillFields(null, p, "", "pw");55+ expect(onChange).toHaveBeenCalledTimes(1);56+ });57+});58+59+describe("T-FL04–T-FL05: framework-controlled inputs (setNativeValue)", () => {60+ // T-FL04: React installs its own value setter on the *instance*; setNativeValue61+ // must call the *prototype* setter so React's value tracker observes the change.62+ // We emulate React's tracker by shadowing the instance value descriptor and63+ // asserting the prototype setter still updated the underlying value AND that64+ // the synthetic input event carried the new value.65+ it("T-FL04: setNativeValue bypasses a React-style instance setter via the prototype setter", () => {66+ const el = makeInput({ type: "text" });67+ let trackerSawValue: string | null = null;68+ el.addEventListener("input", () => { trackerSawValue = el.value; });69+70+ // React installs an own `value` accessor on the element whose setter71+ // swallows direct `.value =` assignment (its value-tracker). setNativeValue72+ // must reach the *prototype* setter to update the real value slot. The73+ // getter here delegates to the prototype so reads still work.74+ const protoDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!;75+ let instanceSetterCalled = false;76+ Object.defineProperty(el, "value", {77+ configurable: true,78+ get() { return protoDesc.get!.call(el); },79+ set() { instanceSetterCalled = true; /* React swallows direct assignment */ },80+ });81+82+ setNativeValue(el, "from-peach");83+84+ expect(instanceSetterCalled).toBe(false); // went around the instance setter85+ expect(el.value).toBe("from-peach"); // prototype setter wrote the slot86+ expect(trackerSawValue).toBe("from-peach"); // event fired AFTER the value was set87+ });88+89+ // T-FL05: Vue (and any framework) relies on the same input/change emission.90+ it("T-FL05: setNativeValue emits input then change for Vue-style v-model", () => {91+ const el = makeInput({ type: "text" });92+ const seq: string[] = [];93+ el.addEventListener("input", () => seq.push("input"));94+ el.addEventListener("change", () => seq.push("change"));95+ setNativeValue(el, "x");96+ expect(seq).toEqual(["input", "change"]);97+ });98+});99+100+describe("T-FL06: cross-origin / wrong-domain guard", () => {101+ // T-FL06: a true cross-origin iframe can't be scripted from jsdom, but the102+ // same defensive path fires when a form's action points at a different103+ // registrable domain (phished/relayed form). fillFields must refuse to fill104+ // and must not throw.105+ it("T-FL06: form action on a foreign domain → fields are not filled, no crash", () => {106+ const formEl = document.createElement("form");107+ formEl.action = "https://evil.example.org/collect";108+ document.body.appendChild(formEl);109+ const u = makeInput({ type: "text" }, formEl);110+ const p = makeInput({ type: "password" }, formEl);111+ expect(() =>112+ fillFields(u, p, "alice", "s3cret", formEl, "mybank.example.com")113+ ).not.toThrow();114+ expect(u.value).toBe("");115+ expect(p.value).toBe("");116+ });117+});118+119+describe("T-FL07–T-FL09: card fill + validation", () => {120+ // T-FL07: every card field that exists gets its value.121+ it("T-FL07: fills number, expiry combo, cvc, and cardholder name", () => {122+ const number = makeInput({ autocomplete: "cc-number" });123+ const expiryCombo = makeInput({ autocomplete: "cc-exp" });124+ const cvc = makeInput({ autocomplete: "cc-csc" });125+ const name = makeInput({ autocomplete: "cc-name" });126+ fillCardForm(127+ { number, expMonth: null, expYear: null, expiryCombo, cvc, name },128+ { cardNumber: "4111111111111111", expMonth: "08", expYear: "2027", cvc: "123", cardholderName: "Alice Wong" }129+ );130+ expect(number.value).toBe("4111 1111 1111 1111"); // formatted in 4-digit groups131+ expect(expiryCombo.value).toBe("08/27");132+ expect(cvc.value).toBe("123");133+ expect(name.value).toBe("Alice Wong");134+ });135+136+ // T-FL08: Luhn check — guards against a fill writing a transposed/typo'd PAN.137+ it("T-FL08: passesLuhn accepts valid PANs and rejects invalid ones", () => {138+ expect(passesLuhn("4111111111111111")).toBe(true); // canonical Visa test number139+ expect(passesLuhn("4111 1111 1111 1111")).toBe(true); // whitespace tolerated140+ expect(passesLuhn("4111111111111112")).toBe(false); // last digit wrong141+ });142+143+ // T-FL09: network detection from the leading digits drives the card icon.144+ it("T-FL09: detectCardNetwork keys off the issuer prefix", () => {145+ expect(detectCardNetwork("4111111111111111")).toBe("visa");146+ expect(detectCardNetwork("5500005555555559")).toBe("mastercard");147+ expect(detectCardNetwork("340000000000009")).toBe("amex");148+ expect(detectCardNetwork("6011000000000004")).toBe("discover");149+ expect(detectCardNetwork("9999999999999999")).toBe("unknown");150+ });151+152+ it("T-FL09b: amex (15-digit) numbers format in 4-6-5 groups", () => {153+ expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");154+ expect(extractLast4("3400 000000 00009")).toBe("0009");155+ });156+});157+158+describe("T-FL10–T-FL12: TOTP fill, preservation, and the return contract", () => {159+ // T-FL10: TOTP code goes into an empty one-time-code field.160+ it("T-FL10: fillTOTPCode inserts the code into an empty visible field", () => {161+ const totp = makeInput({ type: "text", autocomplete: "one-time-code" });162+ expect(fillTOTPCode(totp, "123456")).toBe(true);163+ expect(totp.value).toBe("123456");164+ });165+166+ it("T-FL10b: fillTOTPCode refuses to overwrite a non-empty field", () => {167+ const totp = makeInput({ type: "text", autocomplete: "one-time-code" });168+ setNativeValue(totp, "000000");169+ expect(fillTOTPCode(totp, "123456")).toBe(false);170+ expect(totp.value).toBe("000000");171+ });172+173+ // T-FL11: only the targeted fields are mutated — a stray "remember me" or174+ // search box on the same page keeps its value.175+ it("T-FL11: fields not passed to fillFields are left untouched", () => {176+ const u = makeInput({ type: "text" });177+ const p = makeInput({ type: "password" });178+ const other = makeInput({ type: "text" });179+ setNativeValue(other, "do-not-touch");180+ fillFields(u, p, "alice", "s3cret");181+ expect(other.value).toBe("do-not-touch");182+ });183+184+ // T-FL12: the brief assumes fillFields returns the filled set; the real API185+ // returns void and the caller (index.ts recordFillSuccess) tracks success by186+ // the elements it passed. We pin the actual contract: fillFields returns187+ // undefined, and an invisible password is intentionally skipped — which is how188+ // a caller can tell a fill was partial. (See INTEGRATION-CONTRACT.md.)189+ it("T-FL12: fillFields returns void and skips an invisible password field", () => {190+ const u = makeInput({ type: "text" });191+ const p = makeInput({ type: "password" });192+ p.style.display = "none"; // invisible → isVisible() false → not filled193+ const result = fillFields(u, p, "alice", "s3cret");194+ expect(result).toBeUndefined();195+ expect(u.value).toBe("alice");196+ expect(p.value).toBe(""); // skipped because not visible197+ });198+});src/drafts/AUTOFILL-TESTS/content.interaction.test.ts+115 −0
@@ -0,0 +1,115 @@1+// @vitest-environment jsdom2+//3+// Test File 12 — interaction.ts target resolution (T-IT01–T-IT06).4+//5+// resolveAutofillInteractionTargets is the small, pure decision that turns a set6+// of detected fields + the clicked anchor into "what do we fill, and do we even7+// open?". It is deliberately narrow: it does NOT scope by form (the caller hands8+// it a pre-scoped field list) and it does NOT inspect the live DOM. The tests9+// below pin its real contract; where the brief assumed broader behavior10+// (form-scoping, email-first shape), the deviation is noted inline and in11+// INTEGRATION-CONTRACT.md.12+13+import { beforeEach, describe, expect, it } from "vitest";14+15+import type { DetectedField } from "../../content/detect";16+import { resolveAutofillInteractionTargets } from "../../content/interaction";17+import { makeInput } from "./_setup";18+19+beforeEach(() => {20+ document.body.innerHTML = "";21+});22+23+function field(type: DetectedField["type"], el: HTMLInputElement): DetectedField {24+ return { type, el };25+}26+27+describe("resolveAutofillInteractionTargets", () => {28+ // T-IT01: a standard form resolves both the username and password elements and29+ // opens.30+ it("T-IT01: standard form → {usernameEl, passwordEl} and shouldOpen", () => {31+ const u = makeInput({ type: "text" });32+ const p = makeInput({ type: "password" });33+ const targets = resolveAutofillInteractionTargets(p, [field("username", u), field("password", p)]);34+ expect(targets.usernameEl).toBe(u);35+ expect(targets.passwordEl).toBe(p);36+ expect(targets.emailEl).toBeNull();37+ expect(targets.shouldOpen).toBe(true);38+ });39+40+ // T-IT02: email-first step has no password. The function treats a lone email41+ // as the identity field (usernameEl), so the caller fills entry.username into42+ // it; emailEl stays null because there is no *separate* username field. (The43+ // brief's {emailEl, passwordEl:null} shape predates this consolidation.)44+ it("T-IT02: email-first form → email becomes usernameEl, passwordEl null", () => {45+ const e = makeInput({ type: "email" });46+ const targets = resolveAutofillInteractionTargets(e, [field("email", e)]);47+ expect(targets.usernameEl).toBe(e);48+ expect(targets.passwordEl).toBeNull();49+ expect(targets.emailEl).toBeNull();50+ // shouldOpen depends on isAnyCredentialForm; an email field with no auth51+ // context on a "/" path is not yet a credential form, so passive open is52+ // gated. An explicit summon overrides (next test).53+ });54+55+ // T-IT02b: distinct username AND email → both tracked separately.56+ it("T-IT02b: distinct username + email → emailEl is tracked alongside usernameEl", () => {57+ const u = makeInput({ type: "text", autocomplete: "username" });58+ const e = makeInput({ type: "email", autocomplete: "email" });59+ const p = makeInput({ type: "password" });60+ const targets = resolveAutofillInteractionTargets(61+ u,62+ [field("username", u), field("email", e), field("password", p)]63+ );64+ expect(targets.usernameEl).toBe(u);65+ expect(targets.emailEl).toBe(e);66+ expect(targets.passwordEl).toBe(p);67+ });68+69+ // T-IT03: password-only step → passwordEl set, usernameEl null, and it opens70+ // (isPasswordStepForm).71+ it("T-IT03: password-only form → passwordEl set, usernameEl null, shouldOpen", () => {72+ const p = makeInput({ type: "password", autocomplete: "current-password" });73+ const targets = resolveAutofillInteractionTargets(p, [field("password", p)]);74+ expect(targets.passwordEl).toBe(p);75+ expect(targets.usernameEl).toBeNull();76+ expect(targets.shouldOpen).toBe(true);77+ });78+79+ // T-IT04: no qualifying fields → do not open (passive).80+ it("T-IT04: empty field set + passive → shouldOpen false", () => {81+ const anchor = makeInput({ type: "text" });82+ const targets = resolveAutofillInteractionTargets(anchor, []);83+ expect(targets.shouldOpen).toBe(false);84+ expect(targets.usernameEl).toBeNull();85+ expect(targets.passwordEl).toBeNull();86+ });87+88+ // T-IT05: the function resolves targets from the field list it is GIVEN — form89+ // scoping is the caller's job (index.ts calls detectLoginFields per the focused90+ // field's context). With explicitSummon, a bare anchor still resolves as a91+ // fallback target so an icon click always opens something.92+ it("T-IT05: explicit summon on a bare password anchor falls back to the anchor", () => {93+ const anchor = makeInput({ type: "password" });94+ const targets = resolveAutofillInteractionTargets(anchor, [], true);95+ expect(targets.shouldOpen).toBe(true);96+ expect(targets.passwordEl).toBe(anchor);97+ });98+99+ it("T-IT05b: explicit summon on a bare username anchor falls back to the anchor", () => {100+ const anchor = makeInput({ type: "text" });101+ const targets = resolveAutofillInteractionTargets(anchor, [], true);102+ expect(targets.shouldOpen).toBe(true);103+ expect(targets.usernameEl).toBe(anchor);104+ });105+106+ // T-IT06: a detached anchor with no detected fields resolves to a non-opening107+ // result rather than throwing — the graceful-abort path index.ts relies on.108+ it("T-IT06: detached anchor + empty fields → shouldOpen false, no throw", () => {109+ const detached = document.createElement("input");110+ detached.type = "text"; // never appended to the document111+ let targets!: ReturnType<typeof resolveAutofillInteractionTargets>;112+ expect(() => { targets = resolveAutofillInteractionTargets(detached, []); }).not.toThrow();113+ expect(targets.shouldOpen).toBe(false);114+ });115+});src/drafts/AUTOFILL-TESTS/content.policy.test.ts+126 −0
@@ -0,0 +1,126 @@1+// @vitest-environment jsdom2+//3+// Test File 11 — policy.ts site password policy (T-PO01–T-PO06).4+//5+// detectPasswordPolicy scrapes the page so Peach's generator can produce a6+// password the site will actually accept. Its real inputs are: the password7+// field's maxLength attribute, its pattern attribute, and nearby descriptive8+// text (aria-describedby, hints, labels). It does NOT read a minlength attribute9+// and it inspects only the first password field — where the brief assumed10+// otherwise (T-PO01 minlength, T-PO05 generator wiring, T-PO06 multi-field11+// "most restrictive"), the deviation is pinned and documented in12+// INTEGRATION-CONTRACT.md so the gap is explicit, not silently "passing".13+14+import { beforeEach, describe, expect, it } from "vitest";15+16+import { detectPasswordPolicy, extractNearbyText } from "../../content/policy";17+18+beforeEach(() => {19+ document.body.innerHTML = "";20+});21+22+function pwField(html: string): void {23+ document.body.innerHTML = html;24+}25+26+describe("T-PO01–T-PO04: attribute / pattern / text extraction", () => {27+ // T-PO01: minLength is NOT read from a minlength attribute — only from the28+ // pattern or descriptive text. This test pins the real source (pattern) and29+ // documents that a bare minlength attribute yields nothing.30+ it("T-PO01: minLength comes from the pattern, not the minlength attribute", () => {31+ pwField('<input type="password" minlength="12" />');32+ expect(detectPasswordPolicy().minLength).toBeUndefined();33+34+ pwField('<input type="password" pattern=".{8,}" />');35+ expect(detectPasswordPolicy().minLength).toBe(8);36+ });37+38+ // T-PO02: maxLength IS read directly from the attribute.39+ it("T-PO02: maxlength attribute → policy.maxLength", () => {40+ pwField('<input type="password" maxlength="64" />');41+ expect(detectPasswordPolicy().maxLength).toBe(64);42+ });43+44+ // T-PO03: a range pattern yields both bounds; forbidden characters are derived45+ // from negation TEXT (not the pattern). Pin both real mechanisms.46+ it("T-PO03: range pattern → min+max; negation text → forbidden chars", () => {47+ pwField('<input type="password" pattern=".{10,32}" />');48+ const p1 = detectPasswordPolicy();49+ expect(p1.minLength).toBe(10);50+ expect(p1.maxLength).toBe(32);51+52+ pwField(53+ '<div><input type="password" id="pw" aria-describedby="rule" />' +54+ '<p id="rule">Password cannot contain ` ~ < > characters.</p></div>'55+ );56+ const p2 = detectPasswordPolicy();57+ expect(p2.forbiddenChars).toBeTruthy();58+ for (const ch of ["`", "~", "<", ">"]) expect(p2.forbiddenChars).toContain(ch);59+ });60+61+ // T-PO04: no policy signals anywhere → an empty policy object (the generator62+ // then uses its own defaults).63+ it("T-PO04: no attributes or hints → empty policy", () => {64+ pwField('<input type="password" />');65+ expect(detectPasswordPolicy()).toEqual({});66+ });67+});68+69+describe("T-PO01b: human-readable length ranges in nearby text", () => {70+ // The most common real-world phrasing is "8-20 characters" in a hint element.71+ it("dash range '8-20 characters' → min 8, max 20", () => {72+ pwField(73+ '<div><input type="password" id="pw" aria-describedby="h" />' +74+ '<span id="h">Use 8-20 characters.</span></div>'75+ );76+ const p = detectPasswordPolicy();77+ expect(p.minLength).toBe(8);78+ expect(p.maxLength).toBe(20);79+ });80+81+ it("'at least 12 characters' → min 12", () => {82+ pwField(83+ '<div><input type="password" id="pw" aria-describedby="h" />' +84+ '<small id="h">Must be at least 12 characters.</small></div>'85+ );86+ expect(detectPasswordPolicy().minLength).toBe(12);87+ });88+});89+90+describe("T-PO05–T-PO06: documented scope boundaries", () => {91+ // T-PO05 (generator integration) lives in the generator's own suite, not here:92+ // detectPasswordPolicy only PRODUCES the policy. We pin the shape the generator93+ // consumes so a field rename would break a real contract.94+ it("T-PO05: produced policy uses the PasswordPolicy field names the generator reads", () => {95+ pwField('<input type="password" pattern=".{6,18}" maxlength="18" />');96+ const p = detectPasswordPolicy();97+ expect(Object.keys(p).sort()).toEqual(["maxLength", "minLength"]);98+ });99+100+ // T-PO06: only the FIRST new-password/password field is inspected. "Most101+ // restrictive across multiple fields" is not implemented — pin the actual102+ // single-field behavior (new-password is preferred by selector order).103+ it("T-PO06: reads the first matching password field (new-password preferred)", () => {104+ pwField(105+ '<input type="password" autocomplete="new-password" pattern=".{16,}" />' +106+ '<input type="password" autocomplete="current-password" />'107+ );108+ expect(detectPasswordPolicy().minLength).toBe(16);109+ });110+});111+112+describe("extractNearbyText", () => {113+ // extractNearbyText is the scraping primitive; null input must be safe.114+ it("returns empty string for a null field", () => {115+ expect(extractNearbyText(null)).toBe("");116+ });117+118+ it("pulls aria-describedby, title, placeholder, and aria-label together", () => {119+ document.body.innerHTML =120+ '<input type="password" id="pw" title="t-text" placeholder="p-text" ' +121+ 'aria-label="a-text" aria-describedby="d" /><span id="d">d-text</span>';122+ const el = document.querySelector<HTMLInputElement>("#pw")!;123+ const text = extractNearbyText(el);124+ for (const s of ["t-text", "p-text", "a-text", "d-text"]) expect(text).toContain(s);125+ });126+});