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
4 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 −0
src/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+});

view file

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+});

view file

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+});

view file

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+});

view file