ce63d2b47335

test: autofill layer — 13 vitest files integrated into src/content (smoke, detect, dismissal, field-icon, field-interaction, fill, initial-scan, interaction, message-routing, policy, save, selector-ui, totp-toast addendum); imports rewired ../../content→./, drop unused beforeAll; full suite 117 files/3468 green, 6 skipIf pending addenda (v3.9.9)

time
2026-06-12 13:50 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
autofill tests integration
parent
7f3e1f92d029
13 files src/content/_smoke.test.ts+25 −0src/content/content.detect.test.ts+390 −0src/content/content.dismissal.test.ts+268 −0src/content/content.field-icon.test.ts+128 −0src/content/content.field-interaction.test.ts+194 −0src/content/content.fill.test.ts+198 −0src/content/content.initial-scan.test.ts+144 −0src/content/content.interaction.test.ts+115 −0src/content/content.message-routing.test.ts+184 −0src/content/content.policy.test.ts+126 −0src/content/content.save.test.ts+136 −0src/content/content.selector-ui.test.ts+107 −0src/content/content.totp-toast.addendum.test.ts+59 −0
src/content/_smoke.test.ts+25 −0
@@ -0,0 +1,25 @@1+// @vitest-environment jsdom2+//3+// Smoke test: proves the heavy content-script modules import cleanly under the4+// shared chrome mock. If this fails, every index.ts/inject.ts-dependent test5+// below it is unrunnable — so it gates the whole suite.6+7+import "./_setup";8+import { describe, expect, it } from "vitest";9+10+describe("AUTOFILL-TESTS harness smoke", () => {11+  it("imports index.ts seams without throwing", async () => {12+    const mod = await import("./index");13+    expect(typeof mod.handleFieldInteraction).toBe("function");14+    expect(typeof mod.initialScan).toBe("function");15+    expect(typeof mod.wasAlreadyFilled).toBe("function");16+    expect(mod._testSeam).toBeTruthy();17+    expect(typeof mod._testSeam.DISMISS_LOCK_MS).toBe("number");18+  });19+20+  it("imports inject.ts UI exports without throwing", async () => {21+    const mod = await import("./inject");22+    expect(typeof mod.showAutofillSelector).toBe("function");23+    expect(typeof mod.injectFieldIcon).toBe("function");24+  });25+});

view file

src/content/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 "./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 "./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/content/content.dismissal.test.ts+268 −0
@@ -0,0 +1,268 @@1+// @vitest-environment jsdom2+//3+// Test File 1 — index.ts 3-layer dismissal model (T-D01–T-D15).4+//5+// This is the most regression-prone logic in the project (v1.4.14 broke it6+// repeatedly). Three independent layers each prevent the selector reopening:7+//   Layer 1 — lastDismissedField: per-field lock until focus genuinely leaves.8+//   Layer 2 — dismissLock (DISMISS_LOCK_MS=800): global post-dismiss cooldown.9+//   Layer 3 — wasAlreadyFilled: filledForms (form-level, permanent) + pageFilledAt10+//             (page-level, PAGE_FILL_GRACE_MS=3000 window).11+//12+// All three short-circuit handleFieldInteraction BEFORE the ML race, so we drive13+// them through the exported seams: _testSeam (read/write lastDismissedField,14+// read dismissLock), the captured onDismiss callback (to fire a real dismiss →15+// startDismissLock), and the captured onFillFields callback (to fire a real fill16+// → recordFillSuccess). Fake timers are used ONLY for the expiry windows, per17+// the brief's quality bar.18+19+import { vi } from "vitest";20+21+const { predictBatch, dismissSpy } = vi.hoisted(() => ({22+  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),23+  dismissSpy: vi.fn(),24+}));25+26+vi.mock("./inject", () => ({27+  injectFieldIcon: vi.fn(() => vi.fn()),28+  showAutofillSelector: vi.fn(() => dismissSpy),29+  showTOTPSelector: vi.fn(() => dismissSpy),30+  showCardSelector: vi.fn(() => dismissSpy),31+  showRememberSiteToast: vi.fn(),32+  showPasskeyAvailable: vi.fn(),33+  showPasskeyProGate: vi.fn(),34+  showPasskeyRegistrationOffer: vi.fn(),35+}));36+vi.mock("./content-ml-detector", () => ({37+  contentMLDetector: { predictBatch },38+}));39+40+import { afterEach, beforeEach, describe, expect, it } from "vitest";41+import "./_setup";42+import { resetChromeMock, setMessageResponder, makeInput, createLoginForm } from "./_setup";43+import { handleFieldInteraction, wasAlreadyFilled, _testSeam } from "./index";44+import { showAutofillSelector } from "./inject";45+46+const showSelector = showAutofillSelector as unknown as ReturnType<typeof vi.fn>;47+const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];48+49+function lastOptions(): any {50+  const calls = showSelector.mock.calls;51+  return calls.length ? calls[calls.length - 1]![3] : undefined;52+}53+54+function withEntries(): void {55+  setMessageResponder((msg) =>56+    msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }57+  );58+}59+60+let urlCounter = 0;61+62+beforeEach(async () => {63+  // index.ts has no public reset for pageFilledAt, but its url-watcher observer64+  // nulls it whenever location.href changes. Push a fresh URL and mutate the65+  // body so that observer fires — giving every test a clean fill-grace slate.66+  try {67+    history.pushState({}, "", `/t${++urlCounter}`);68+  } catch { /* jsdom history unavailable — fall through */ }69+  document.body.innerHTML = "";70+  await new Promise((r) => setTimeout(r, 0)); // let the url-watcher observer run71+72+  resetChromeMock();73+  showSelector.mockClear();74+  dismissSpy.mockClear();75+  predictBatch.mockReset();76+  predictBatch.mockReturnValue(Promise.resolve([]));77+  _testSeam.setLastDismissedField(null);78+});79+80+afterEach(async () => {81+  vi.useRealTimers();82+  await new Promise((r) => setTimeout(r, 0)); // flush index.ts's url-watcher observer83+});84+85+// ── Layer 1 — per-field lock (lastDismissedField) ────────────────────────────86+describe("T-D01–T-D04: Layer 1 per-field lock", () => {87+  // T-D01: a field whose selector was dismissed must not reopen on re-focus.88+  it("T-D01: re-focusing the dismissed field does NOT reopen the selector", async () => {89+    const { password } = createLoginForm();90+    withEntries();91+    _testSeam.setLastDismissedField(password); // simulate "user pressed Escape on this field"92+    await handleFieldInteraction(password);93+    await new Promise((r) => setTimeout(r, 20));94+    expect(showSelector).not.toHaveBeenCalled();95+  });96+97+  // T-D02 / T-D04: the lock is field-specific — focusing a DIFFERENT field opens,98+  // and doing so clears the stale per-field lock.99+  it("T-D02/T-D04: a different field opens (lock is field-specific)", async () => {100+    const { username, password } = createLoginForm();101+    withEntries();102+    _testSeam.setLastDismissedField(username);103+    await handleFieldInteraction(password);104+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());105+    expect(_testSeam.getLastDismissedField()).toBeNull(); // stale lock cleared106+  });107+108+  // T-D03: once the per-field lock is cleared (focus moved away and back), the109+  // same field opens again.110+  it("T-D03: clearing the lock lets the same field open again", async () => {111+    const { password } = createLoginForm();112+    withEntries();113+    _testSeam.setLastDismissedField(null);114+    await handleFieldInteraction(password);115+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());116+  });117+});118+119+// ── Layer 2 — global cooldown (DISMISS_LOCK_MS) ──────────────────────────────120+describe("T-D05–T-D07: Layer 2 post-dismiss global cooldown", () => {121+  // T-D05: immediately after a dismiss, focusing ANY other field is blocked by122+  // the global lock — not just the dismissed field.123+  it("T-D05: dismiss → focus a different field → blocked by global cooldown", async () => {124+    const { username, password } = createLoginForm();125+    withEntries();126+    await handleFieldInteraction(password);127+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());128+129+    const onDismiss = lastOptions().onDismiss as () => void;130+    vi.useFakeTimers();131+    onDismiss(); // real dismiss → startDismissLock()132+    expect(_testSeam.isDismissLocked()).toBe(true);133+134+    showSelector.mockClear();135+    await handleFieldInteraction(username); // different field, but cooldown active136+    expect(showSelector).not.toHaveBeenCalled();137+138+    // T-D06: after DISMISS_LOCK_MS the global lock clears.139+    vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);140+    expect(_testSeam.isDismissLocked()).toBe(false);141+  });142+143+  // T-D06: with the cooldown expired, a fresh focus opens normally again.144+  it("T-D06: after the cooldown expires, the selector opens again", async () => {145+    const { password } = createLoginForm();146+    withEntries();147+    await handleFieldInteraction(password);148+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());149+    const onDismiss = lastOptions().onDismiss as () => void;150+151+    vi.useFakeTimers();152+    onDismiss();153+    vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);154+    expect(_testSeam.isDismissLocked()).toBe(false);155+    vi.useRealTimers();156+157+    // New field after expiry → opens. (lastDismissedField was set to the old158+    // password element; a different element is unaffected.)159+    _testSeam.setLastDismissedField(null);160+    const fresh = makeInput({ type: "password", autocomplete: "current-password" });161+    showSelector.mockClear();162+    await handleFieldInteraction(fresh);163+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());164+  });165+166+  // T-D07: multiple rapid focuses during the cooldown all suppress — only the167+  // original (pre-dismiss) open ever happened.168+  it("T-D07: rapid focus events during cooldown are all suppressed", async () => {169+    const { username, password } = createLoginForm();170+    withEntries();171+    await handleFieldInteraction(password);172+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());173+    const onDismiss = lastOptions().onDismiss as () => void;174+175+    vi.useFakeTimers();176+    onDismiss();177+    showSelector.mockClear();178+    await handleFieldInteraction(username);179+    await handleFieldInteraction(password);180+    await handleFieldInteraction(username);181+    expect(showSelector).not.toHaveBeenCalled();182+    vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);183+  });184+});185+186+// ── Layer 3 — fill-success grace (wasAlreadyFilled) ──────────────────────────187+describe("T-D08–T-D12: Layer 3 fill-success grace", () => {188+  // Drive a real fill by capturing the onFillFields callback the selector was189+  // opened with and invoking it — that calls recordFillSuccess internally.190+  async function openAndFill() {191+    const { form, username, password } = createLoginForm();192+    withEntries();193+    await handleFieldInteraction(password);194+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());195+    const onFill = lastOptions().onFillFields as (u: string, p: string, e?: string) => void;196+    onFill("alice", "s3cret");197+    return { form, username, password };198+  }199+200+  // T-D08 / T-D11: immediately after a fill, both the form-level and page-level201+  // checks report "already filled".202+  it("T-D08/T-D11: after fill, wasAlreadyFilled is true (form-level and page-level)", async () => {203+    const { password } = await openAndFill();204+    expect(wasAlreadyFilled(password)).toBe(true); // form-level (filledForms)205+    const formless = makeInput({ type: "password" }); // not in a <form>206+    expect(wasAlreadyFilled(formless)).toBe(true); // page-level grace window207+  });208+209+  // T-D09 / T-D12: once the page grace window expires, a formless field is no210+  // longer suppressed (a field still inside the filled form stays locked — that211+  // is the permanent form-level guard, documented below).212+  it("T-D09/T-D12: after the grace window, a formless field is no longer suppressed", async () => {213+    await openAndFill();214+    const formless = makeInput({ type: "password" });215+    expect(wasAlreadyFilled(formless)).toBe(true);216+    vi.useFakeTimers();217+    vi.advanceTimersByTime(5000); // well past PAGE_FILL_GRACE_MS218+    expect(wasAlreadyFilled(formless)).toBe(false);219+    vi.useRealTimers();220+  });221+222+  // T-D10: the brief expected the grace to be form-scoped (form B opens during223+  // form A's grace). The implementation's page-level grace is NOT form-scoped:224+  // for PAGE_FILL_GRACE_MS, EVERY field is suppressed. After the window, the225+  // filled form A's fields stay locked (filledForms is permanent) while a field226+  // in an unrelated form B opens. We pin the ACTUAL behavior. See227+  // INTEGRATION-CONTRACT.md.228+  it("T-D10: during the page grace every field is suppressed; after it, only the filled form stays locked", async () => {229+    const { password: aPassword } = await openAndFill();230+    // Build a second, independent form B.231+    const formB = document.createElement("form");232+    document.body.appendChild(formB);233+    const bPassword = makeInput({ type: "password", autocomplete: "current-password" }, formB);234+235+    // During grace: B is suppressed too (page-level grace is global).236+    expect(wasAlreadyFilled(bPassword)).toBe(true);237+238+    vi.useFakeTimers();239+    vi.advanceTimersByTime(5000);240+    // After grace: A's form is permanently locked, B is free.241+    expect(wasAlreadyFilled(aPassword)).toBe(true); // filledForms (permanent)242+    expect(wasAlreadyFilled(bPassword)).toBe(false);243+    vi.useRealTimers();244+  });245+});246+247+// ── Edge cases (T-D13–T-D15) ─────────────────────────────────────────────────248+describe("T-D13–T-D15: edge cases and documented boundaries", () => {249+  // T-D13: a second concurrent interaction on the same field is debounced by250+  // pendingInteractions, so a rapid focus/click pair cannot double-open (the251+  // visible "flicker" regression). (Mirrors T-F11 from the dedicated angle.)252+  it("T-D13: concurrent interactions on one field open it at most once (no flicker)", async () => {253+    const { password } = createLoginForm();254+    withEntries();255+    await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);256+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());257+    expect(showSelector).toHaveBeenCalledTimes(1);258+  });259+260+  // T-D14 (scroll-into-view) and T-D15 (isTrusted/programmatic focus): these are261+  // not decisions made inside handleFieldInteraction — the function is gesture-262+  // agnostic and is invoked by the focusin listener regardless of isTrusted.263+  // They belong to the Playwright e2e layer (real user gestures vs synthetic).264+  // Documented in INTEGRATION-CONTRACT.md rather than asserted with a false265+  // unit-level proxy that would always pass.266+  it.todo("T-D14: focus after scroll-into-view opens normally (e2e — real gesture)");267+  it.todo("T-D15: programmatic focus (isTrusted=false) suppressed (e2e — real gesture)");268+});

view file

src/content/content.field-icon.test.ts+128 −0
@@ -0,0 +1,128 @@1+// @vitest-environment jsdom2+//3+// Test File 6 — inject.ts injectFieldIcon (T-IC01–T-IC08).4+//5+// injectFieldIcon renders the small peach button into the LIGHT DOM (appended to6+// document.body, position:fixed), so unlike the selector it is fully observable7+// from a unit test. We import the REAL inject.ts and assert on the button it8+// produces: presence, accessibility, click wiring, teardown, and the9+// too-narrow-field hide rule. Where the brief assumed behavior that lives10+// elsewhere (idempotency, autocomplete policy), it is documented, not faked.11+12+import "./_setup"; // chrome mock + ResizeObserver polyfill (observeAnchorPosition needs it)13+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";14+import { injectFieldIcon } from "./inject";15+import { makeInput, makeFieldVisible } from "./_setup";16+17+const ICON_SELECTOR = 'button[aria-label="Peach autofill"]';18+19+beforeEach(() => {20+  document.body.innerHTML = "";21+});22+afterEach(() => {23+  document.body.innerHTML = "";24+});25+26+describe("T-IC01, T-IC08: rendering and accessibility", () => {27+  // T-IC01: the icon button is injected into the document for a normal field.28+  it("T-IC01: injects a position:fixed button into the document", () => {29+    const field = makeInput({ type: "password" });30+    const cleanup = injectFieldIcon(field, () => {});31+    const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR);32+    expect(btn).not.toBeNull();33+    expect(btn!.style.position).toBe("fixed");34+    cleanup();35+  });36+37+  // T-IC08: the button carries an aria-label and title for screen readers.38+  it("T-IC08: button has an aria-label and title", () => {39+    const field = makeInput({ type: "password" });40+    const cleanup = injectFieldIcon(field, () => {});41+    const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;42+    expect(btn.getAttribute("aria-label")).toBe("Peach autofill");43+    expect(btn.title).toBeTruthy();44+    cleanup();45+  });46+});47+48+describe("T-IC02: click opens the selector", () => {49+  // T-IC02: clicking the icon invokes the onClick callback (which, in index.ts,50+  // is what summons the selector).51+  it("T-IC02: clicking the icon calls onClick exactly once", () => {52+    const field = makeInput({ type: "password" });53+    const onClick = vi.fn();54+    const cleanup = injectFieldIcon(field, onClick);55+    const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;56+    btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));57+    expect(onClick).toHaveBeenCalledTimes(1);58+    cleanup();59+  });60+});61+62+describe("T-IC04: teardown", () => {63+  // T-IC04: the returned cleanup removes the button — index.ts calls it when the64+  // field leaves the DOM, so a stale icon never lingers.65+  it("T-IC04: cleanup() removes the injected button", () => {66+    const field = makeInput({ type: "password" });67+    const cleanup = injectFieldIcon(field, () => {});68+    expect(document.querySelector(ICON_SELECTOR)).not.toBeNull();69+    cleanup();70+    expect(document.querySelector(ICON_SELECTOR)).toBeNull();71+  });72+});73+74+describe("T-IC06: size / narrow-field hide rule", () => {75+  // T-IC06: on a field too narrow to host the icon cleanly (<40px wide), the76+  // button is hidden (opacity 0, pointer-events none) so it never overlaps a77+  // tiny input or an adjacent control.78+  it("T-IC06: a <40px-wide field hides the icon", () => {79+    const field = makeInput({ type: "password" });80+    Object.defineProperty(field, "getBoundingClientRect", {81+      configurable: true,82+      value: () => ({ width: 30, height: 24, top: 10, left: 10, right: 40, bottom: 34, x: 10, y: 10, toJSON: () => ({}) }),83+    });84+    const cleanup = injectFieldIcon(field, () => {});85+    const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;86+    // Read synchronously, before the post-mount requestAnimationFrame fires.87+    expect(btn.style.opacity).toBe("0");88+    expect(btn.style.pointerEvents).toBe("none");89+    cleanup();90+  });91+92+  it("T-IC06b: a normally-wide field shows the icon", () => {93+    const field = makeInput({ type: "password" });94+    makeFieldVisible(field); // 160×32 → wide enough95+    const cleanup = injectFieldIcon(field, () => {});96+    const btn = document.querySelector<HTMLButtonElement>(ICON_SELECTOR)!;97+    expect(btn.style.opacity).toBe("1");98+    cleanup();99+  });100+});101+102+// ── Documented scope boundaries (T-IC03, T-IC05, T-IC07) ─────────────────────103+describe("T-IC03 / T-IC05 / T-IC07: documented boundaries", () => {104+  // T-IC03: injectFieldIcon is NOT idempotent on its own — each call appends a105+  // button. Idempotency is enforced by the caller (index.ts processedInputs),106+  // covered by content.initial-scan.test.ts T-S10. We pin the real behavior so107+  // the contract is explicit.108+  it("T-IC03: injectFieldIcon itself is not idempotent (dedup is the caller's job)", () => {109+    const field = makeInput({ type: "password" });110+    const c1 = injectFieldIcon(field, () => {});111+    const c2 = injectFieldIcon(field, () => {});112+    expect(document.querySelectorAll(ICON_SELECTOR).length).toBe(2);113+    c1();114+    c2();115+  });116+117+  // T-IC05 (reposition on scroll) is driven by observeAnchorPosition via118+  // ResizeObserver + requestAnimationFrame + scroll listeners. The reflow is not119+  // deterministically observable under jsdom (no real layout); it is verified in120+  // the Playwright e2e.121+  it.todo("T-IC05: icon repositions on scroll (e2e — needs real layout)");122+123+  // T-IC07: injectFieldIcon does not inspect autocomplete; the "don't offer on124+  // current-password where the browser already does" policy, if desired, belongs125+  // to the detect/index gating layer, not the renderer. No such gate exists126+  // today — flagged in INTEGRATION-CONTRACT.md rather than asserted falsely.127+  it.todo("T-IC07: suppress on autocomplete=current-password (no such policy in inject today)");128+});

view file

src/content/content.field-interaction.test.ts+194 −0
@@ -0,0 +1,194 @@1+// @vitest-environment jsdom2+//3+// Test File 2 — index.ts handleFieldInteraction core paths (T-F01–T-F12).4+//5+// handleFieldInteraction is the central decision: ML predict → suppression6+// layers → TOTP/OTP branch → vault query → which surface opens. We mock the7+// boundary it talks to — ./inject (so we can see WHICH surface opened) and8+// ./content-ml-detector (so we can control predict timing) — and drive the REAL9+// exported function with controlled DOM + chrome responses. Nothing about the10+// decision logic itself is mocked.11+12+import { vi } from "vitest";13+14+// Spies shared with the hoisted vi.mock factories must themselves be hoisted.15+const { predictBatch, dismissSpy } = vi.hoisted(() => ({16+  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),17+  dismissSpy: vi.fn(),18+}));19+20+// Observe which inject surface opens. showAutofillSelector returns a dismiss fn.21+vi.mock("./inject", () => ({22+  injectFieldIcon: vi.fn(() => vi.fn()),23+  showAutofillSelector: vi.fn(() => dismissSpy),24+  showTOTPSelector: vi.fn(() => dismissSpy),25+  showCardSelector: vi.fn(() => dismissSpy),26+  showRememberSiteToast: vi.fn(),27+  showPasskeyAvailable: vi.fn(),28+  showPasskeyProGate: vi.fn(),29+  showPasskeyRegistrationOffer: vi.fn(),30+}));31+32+// Control ML predict timing/result per test.33+vi.mock("./content-ml-detector", () => ({34+  contentMLDetector: { predictBatch },35+}));36+37+import { afterEach, beforeEach, describe, expect, it } from "vitest";38+import "./_setup";39+import { setMessageResponder, resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";40+import { handleFieldInteraction } from "./index";41+import { showAutofillSelector, showTOTPSelector } from "./inject";42+43+const showSelector = showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44+const showTOTP = showTOTPSelector as unknown as ReturnType<typeof vi.fn>;45+46+// Reset module-private suppression state between tests by exercising the public47+// surface: a fresh DOM + cleared mocks. handleFieldInteraction reads live DOM48+// each call, so a clean body is a clean slate for everything except the global49+// dismiss lock — which we never trip in this file (no dismissals here).50+beforeEach(() => {51+  document.body.innerHTML = "";52+  resetChromeMock();53+  showSelector.mockClear();54+  showTOTP.mockClear();55+  predictBatch.mockReset();56+  predictBatch.mockReturnValue(Promise.resolve([]));57+});58+59+afterEach(async () => {60+  vi.useRealTimers();61+  // index.ts installs a url-watcher MutationObserver on document.body that62+  // reads location.href. Clearing the DOM each test queues its callback; flush63+  // it here so it runs while jsdom's globals still exist, instead of throwing64+  // "location is not defined" during environment teardown.65+  await new Promise((r) => setTimeout(r, 0));66+});67+68+// Last options object passed to showAutofillSelector (4th arg).69+function lastSelectorOptions(): any {70+  const calls = showSelector.mock.calls;71+  return calls.length ? calls[calls.length - 1]![3] : undefined;72+}73+74+describe("T-F01–T-F02: ML predict timeout race (150ms)", () => {75+  // T-F02: when ML resolves IN TIME with a non-credential verdict for a text76+  // field, handleFieldInteraction suppresses — the selector must NOT open.77+  it("T-F02: ML resolves fast with a suppressing prediction → no selector", async () => {78+    const { username } = createLoginForm();79+    predictBatch.mockReturnValue(Promise.resolve([{ type: "search", confidence: 0.9 }]));80+    await handleFieldInteraction(username); // passive (explicitSummon=false)81+    // give any stray microtasks a tick82+    await new Promise((r) => setTimeout(r, 20));83+    expect(showSelector).not.toHaveBeenCalled();84+  });85+86+  // T-F01: when ML is SLOWER than the 150ms cap, the timeout wins, the87+  // prediction is discarded (treated as null), and the heuristic path proceeds —88+  // so the very same suppressing verdict no longer blocks the selector. This is89+  // the regression that froze autofill on pages with a slow ML worker.90+  it("T-F01: ML slower than 150ms → timeout path taken, selector still opens", async () => {91+    const { username } = createLoginForm();92+    predictBatch.mockReturnValue(93+      new Promise((resolve) => setTimeout(() => resolve([{ type: "search", confidence: 0.9 }]), 250))94+    );95+    setMessageResponder((msg) =>96+      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }97+    );98+    await handleFieldInteraction(username);99+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled(), { timeout: 1000 });100+  });101+});102+103+describe("T-F03–T-F05: vault state + form shape", () => {104+  // T-F03: a locked vault shows the inline unlock prompt (showAutofillSelector105+  // with {locked:true}), never the entry list.106+  it("T-F03: locked vault → selector opened in locked mode", async () => {107+    const { password } = createLoginForm();108+    setMessageResponder((msg) =>109+      msg.type === "AUTOFILL_QUERY" ? { ok: false, error: "vault_locked" } : { ok: true, data: null }110+    );111+    await handleFieldInteraction(password);112+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());113+    expect(lastSelectorOptions()?.locked).toBe(true);114+  });115+116+  // T-F04: unlocked vault with no entries for this domain → signup/identity117+  // surface (isSignup:true), not a credential list.118+  it("T-F04: no domain entries → signup/identity surface", async () => {119+    const { password } = createLoginForm();120+    setMessageResponder((msg) =>121+      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null }122+    );123+    await handleFieldInteraction(password);124+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());125+    expect(lastSelectorOptions()?.isSignup).toBe(true);126+    expect(lastSelectorOptions()?.locked).toBeFalsy();127+  });128+129+  // T-F05: an email-first form (no password on this step) still opens on the130+  // email field — the path must not bail just because there is no password.131+  it("T-F05: email-first form opens on the email field", async () => {132+    const { email } = createEmailFirstForm();133+    setMessageResponder((msg) =>134+      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }135+    );136+    await handleFieldInteraction(email);137+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());138+  });139+});140+141+describe("T-F07: TOTP field path", () => {142+  // T-F07: a lone verification-code field (no login form present) routes to the143+  // TOTP selector, not the credential selector.144+  it("T-F07: standalone one-time-code field → TOTP selector", async () => {145+    const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });146+    setMessageResponder((msg) =>147+      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "t1", title: "Acme 2FA" }] } : { ok: true, data: null }148+    );149+    await handleFieldInteraction(totp);150+    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());151+    expect(showSelector).not.toHaveBeenCalled();152+  });153+});154+155+describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => {156+  // T-F09: autocomplete="off" on the form must not stop Peach offering — the157+  // user installed Peach precisely to override the site's opt-out.158+  it("T-F09: form with autocomplete=off still offers", async () => {159+    const form = document.createElement("form");160+    form.setAttribute("autocomplete", "off");161+    document.body.appendChild(form);162+    makeInput({ type: "text", name: "username", autocomplete: "username" }, form);163+    const password = makeInput({ type: "password", autocomplete: "current-password" }, form);164+    setMessageResponder((msg) =>165+      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }166+    );167+    await handleFieldInteraction(password);168+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());169+  });170+});171+172+describe("T-F10–T-F12: robustness", () => {173+  // T-F10: a field whose ownerDocument is a different (cross-origin-style)174+  // context must be handled without crashing. We approximate by anchoring a175+  // detached input — the function must abort cleanly, never throw.176+  it("T-F10/T-F12: detached field mid-interaction → clean abort, no throw, no selector", async () => {177+    const detached = document.createElement("input");178+    detached.type = "password"; // never attached → isVisible() false179+    await expect(handleFieldInteraction(detached)).resolves.toBeUndefined();180+    expect(showSelector).not.toHaveBeenCalled();181+  });182+183+  // T-F11: two concurrent calls for the SAME field (focusin + icon click racing)184+  // must not double-open — pendingInteractions debounces the second.185+  it("T-F11: concurrent calls for the same field → selector opens at most once", async () => {186+    const { password } = createLoginForm();187+    setMessageResponder((msg) =>188+      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }189+    );190+    await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);191+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());192+    expect(showSelector).toHaveBeenCalledTimes(1);193+  });194+});

view file

src/content/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 "./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/content/content.initial-scan.test.ts+144 −0
@@ -0,0 +1,144 @@1+// @vitest-environment jsdom2+//3+// Test File 3 — index.ts initialScan aggression levels (T-S01–T-S10).4+//5+// initialScan decides where the field icon is injected at page load. Aggression6+// climbs with each pass: level 0 only touches pages with a recognised credential7+// form; level 2 final-pass injects on a bare password field even without8+// surrounding form context (SPA reveal-after-username case). We mock ./inject so9+// injectFieldIcon is observable, and assert on its call count — the real10+// regression surface is "icon missing" or "icon on the wrong field".11+12+import { vi } from "vitest";13+14+const { injectIcon } = vi.hoisted(() => ({ injectIcon: vi.fn(() => vi.fn()) }));15+16+vi.mock("./inject", () => ({17+  injectFieldIcon: injectIcon,18+  showAutofillSelector: vi.fn(() => vi.fn()),19+  showTOTPSelector: vi.fn(() => vi.fn()),20+  showCardSelector: vi.fn(() => vi.fn()),21+  showRememberSiteToast: vi.fn(),22+  showPasskeyAvailable: vi.fn(),23+  showPasskeyProGate: vi.fn(),24+  showPasskeyRegistrationOffer: vi.fn(),25+}));26+vi.mock("./content-ml-detector", () => ({27+  contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) },28+}));29+30+import { afterEach, beforeEach, describe, expect, it } from "vitest";31+import "./_setup";32+import { resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";33+import { initialScan } from "./index";34+35+// Each test uses fresh DOM elements, so the module-private processedInputs36+// WeakSet never collides across tests; injectIcon's count is therefore a clean37+// per-test signal.38+beforeEach(() => {39+  document.body.innerHTML = "";40+  resetChromeMock();41+  injectIcon.mockClear();42+});43+44+afterEach(async () => {45+  await new Promise((r) => setTimeout(r, 0));46+});47+48+describe("T-S01–T-S02: level 0 is conservative", () => {49+  // T-S01: an empty page → scan runs, injects nothing.50+  it("T-S01: no forms at all + level 0 → no icon", () => {51+    initialScan(0);52+    expect(injectIcon).not.toHaveBeenCalled();53+  });54+55+  // T-S02: a non-credential form (search box) → level 0 injects nothing.56+  it("T-S02: non-matching form + level 0 → no icon", () => {57+    const f = document.createElement("form");58+    document.body.appendChild(f);59+    makeInput({ type: "search", name: "q", placeholder: "Search" }, f);60+    initialScan(0);61+    expect(injectIcon).not.toHaveBeenCalled();62+  });63+});64+65+describe("T-S03–T-S06: icons get injected as aggression rises", () => {66+  // T-S03: a standard login form at level 1 → icon injected (on the credential67+  // fields).68+  it("T-S03: standard login form + level 1 → icon injected", () => {69+    createLoginForm();70+    initialScan(1);71+    expect(injectIcon).toHaveBeenCalled();72+  });73+74+  // T-S04: an email-first form (email only) → icon injected on the email field.75+  it("T-S04: email-first form + level 1 → icon injected", () => {76+    createEmailFirstForm();77+    initialScan(1);78+    expect(injectIcon).toHaveBeenCalled();79+  });80+81+  // T-S05: a bare password field (no username) at level 2 final-pass → icon82+  // injected. This is the SPA case where the password appears after the83+  // username step; without the final pass the field would never get an icon.84+  it("T-S05: bare password + level 2 final-pass → icon injected", () => {85+    makeInput({ type: "password", autocomplete: "current-password" });86+    initialScan(2, true);87+    expect(injectIcon).toHaveBeenCalled();88+  });89+90+  // T-S07: multiple independent login forms → each qualifying password field91+  // gets its own icon.92+  it("T-S07: two login forms → an icon per form's password field", () => {93+    createLoginForm();94+    createLoginForm();95+    initialScan(1);96+    // At least two injections — one per password field. (Username fields may add97+    // more; the floor is the two passwords.)98+    expect(injectIcon.mock.calls.length).toBeGreaterThanOrEqual(2);99+  });100+});101+102+describe("T-S08–T-S10: visibility, dynamic forms, idempotency", () => {103+  // T-S08: a display:none form is not a fillable surface — no icon, even at104+  // level 1, because isVisible() rejects its fields.105+  it("T-S08: hidden (display:none) form → no icon", () => {106+    const f = document.createElement("form");107+    f.style.display = "none";108+    document.body.appendChild(f);109+    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);110+    const p = makeInput({ type: "password", autocomplete: "current-password" }, f);111+    // Force isVisible() false by zeroing the rects (display:none isn't computed112+    // for ancestors in jsdom, so neutralise the per-field rect shim too).113+    for (const el of [u, p]) {114+      Object.defineProperty(el, "getBoundingClientRect", {115+        configurable: true,116+        value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),117+      });118+    }119+    initialScan(1);120+    expect(injectIcon).not.toHaveBeenCalled();121+  });122+123+  // T-S09: a form inserted after the first scan gets an icon when the scan runs124+  // again (the MutationObserver path re-invokes the reattach logic; here we125+  // drive the rescan directly to keep the test deterministic — the debounce126+  // timing itself is covered by the Playwright e2e).127+  it("T-S09: a form inserted after the first scan is picked up on rescan", () => {128+    initialScan(1);129+    expect(injectIcon).not.toHaveBeenCalled(); // nothing on the page yet130+    createLoginForm(); // dynamic insertion131+    initialScan(1);132+    expect(injectIcon).toHaveBeenCalled();133+  });134+135+  // T-S10: running the scan twice on the same form injects exactly once —136+  // processedInputs makes it idempotent, preventing duplicate stacked icons.137+  it("T-S10: scanning the same form twice → icon injected once per field", () => {138+    createLoginForm();139+    initialScan(1);140+    const afterFirst = injectIcon.mock.calls.length;141+    initialScan(1);142+    expect(injectIcon.mock.calls.length).toBe(afterFirst); // no new injections143+  });144+});

view file

src/content/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 "./detect";16+import { resolveAutofillInteractionTargets } from "./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/content/content.message-routing.test.ts+184 −0
@@ -0,0 +1,184 @@1+// @vitest-environment jsdom2+//3+// Test File 4 — index.ts message routing (T-M01–T-M13).4+//5+// index.ts registers a single chrome.runtime.onMessage listener that routes6+// SUMMON_AUTOFILL (keyboard shortcut / toolbar button), VAULT_UNLOCKED (retry7+// after unlock), PING, GET_SITE_PASSWORD_POLICY, and the passkey-toast control8+// messages. The harness captures every registered listener; we invoke them with9+// a spy sendResponse and a same-extension sender, then assert the routed effect.10+//11+// Where the brief assumed messages that this listener does not own12+// (DISMISS_AUTOFILL, FILL_AND_SUBMIT), and the Addendum-2 showTotpCopyToast that13+// does not exist yet, the cases are documented rather than faked. See14+// INTEGRATION-CONTRACT.md.15+16+import { vi } from "vitest";17+18+const { predictBatch, dismissSpy } = vi.hoisted(() => ({19+  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),20+  dismissSpy: vi.fn(),21+}));22+23+vi.mock("./inject", () => ({24+  injectFieldIcon: vi.fn(() => vi.fn()),25+  showAutofillSelector: vi.fn(() => dismissSpy),26+  showTOTPSelector: vi.fn(() => dismissSpy),27+  showCardSelector: vi.fn(() => dismissSpy),28+  showRememberSiteToast: vi.fn(),29+  showPasskeyAvailable: vi.fn(),30+  showPasskeyProGate: vi.fn(),31+  showPasskeyRegistrationOffer: vi.fn(),32+}));33+vi.mock("./content-ml-detector", () => ({34+  contentMLDetector: { predictBatch },35+}));36+37+import { afterEach, beforeEach, describe, expect, it } from "vitest";38+import "./_setup";39+import { resetChromeMock, setMessageResponder, messageListeners, createLoginForm, makeInput } from "./_setup";40+import "./index"; // registers the onMessage listener at import time41+import * as inject from "./inject";42+43+const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44+const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType<typeof vi.fn>;45+const SELF_SENDER = { id: "peach-test-extension-id" };46+const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];47+48+// Deliver a message to every registered onMessage listener; return the49+// sendResponse spy so the test can assert the reply.50+function dispatch(msg: any, sender: any = SELF_SENDER) {51+  const sendResponse = vi.fn();52+  for (const l of messageListeners) l(msg, sender, sendResponse);53+  return sendResponse;54+}55+56+function withEntries(): void {57+  setMessageResponder((msg) =>58+    msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }59+  );60+}61+62+let urlCounter = 0;63+beforeEach(async () => {64+  try { history.pushState({}, "", `/m${++urlCounter}`); } catch { /* ignore */ }65+  document.body.innerHTML = "";66+  await new Promise((r) => setTimeout(r, 0));67+  resetChromeMock();68+  showSelector.mockClear();69+  showPasskeyProGate.mockClear();70+  predictBatch.mockReset();71+  predictBatch.mockReturnValue(Promise.resolve([]));72+});73+74+afterEach(async () => {75+  await new Promise((r) => setTimeout(r, 0));76+});77+78+describe("T-M01–T-M02: SUMMON_AUTOFILL", () => {79+  // T-M01: the shortcut on a login form opens the selector for the focused field.80+  it("T-M01: SUMMON_AUTOFILL with a focused login field → selector opens", async () => {81+    const { password } = createLoginForm();82+    password.focus();83+    withEntries();84+    const send = dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });85+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());86+    expect(send).toHaveBeenCalled(); // listener acknowledged87+  });88+89+  // T-M02: the shortcut on a page with no login form must not crash and must not90+  // open the credential selector (it falls back to the search palette).91+  it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => {92+    expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow();93+    expect(showSelector).not.toHaveBeenCalled();94+  });95+});96+97+describe("T-M03–T-M05: VAULT_UNLOCKED", () => {98+  // T-M03: after unlock, the pending field's interaction is retried.99+  it("T-M03: VAULT_UNLOCKED with a pending anchor → re-triggers and reopens", async () => {100+    const { password } = createLoginForm();101+    password.focus();102+    withEntries();103+    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); // opens → sets currentAnchorField104+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());105+    showSelector.mockClear();106+107+    dispatch({ type: "VAULT_UNLOCKED" });108+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); // reopened at the same field109+  });110+111+  // T-M05: VAULT_UNLOCKED with nothing pending and no focused field is a no-op —112+  // it must not throw or open anything.113+  it("T-M05: VAULT_UNLOCKED with no pending interaction → no-op, no crash", async () => {114+    (document.activeElement as HTMLElement | null)?.blur?.();115+    const send = dispatch({ type: "VAULT_UNLOCKED" });116+    await new Promise((r) => setTimeout(r, 20));117+    expect(send).toHaveBeenCalledWith({ ok: true });118+    expect(showSelector).not.toHaveBeenCalled();119+  });120+121+  // T-M04: when a passkey unlock is in flight (passkeySummonActive), VAULT_UNLOCKED122+  // must NOT re-trigger autofill — the passkey toast owns the next step. We arm123+  // passkeySummonActive via the passkey-unlock summon path, then assert the124+  // unlock only dismisses (no fresh credential selector). Ordered last in the125+  // group because the VAULT_UNLOCKED it sends also clears the flag.126+  it("T-M04: VAULT_UNLOCKED while passkeySummonActive → no re-trigger", async () => {127+    // The passkey-unlock branch only fires when there is NO credential form on128+    // the page (vault locked mid-passkey-GET); it anchors the locked prompt on129+    // whatever element is focused (typically the sign-in button).130+    const anchor = makeInput({ type: "text", name: "search-box", placeholder: "Search" });131+    anchor.focus();132+    dispatch({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }); // sets passkeySummonActive133+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());134+    showSelector.mockClear();135+136+    dispatch({ type: "VAULT_UNLOCKED" }); // passkey branch: dismiss only, no reopen137+    await new Promise((r) => setTimeout(r, 20));138+    expect(showSelector).not.toHaveBeenCalled();139+  });140+});141+142+describe("T-M08–T-M10: PING, passkey gate, robustness", () => {143+  // PING is the liveness probe the background uses to confirm the content script144+  // is injected.145+  it("PING → sendResponse({ ok: true })", () => {146+    const send = dispatch({ type: "PING" });147+    expect(send).toHaveBeenCalledWith({ ok: true });148+  });149+150+  // GET_SITE_PASSWORD_POLICY returns the scraped policy for the generator.151+  it("GET_SITE_PASSWORD_POLICY → returns a policy object", () => {152+    document.body.innerHTML = '<input type="password" maxlength="32" />';153+    const send = dispatch({ type: "GET_SITE_PASSWORD_POLICY" });154+    expect(send).toHaveBeenCalledTimes(1);155+    const arg = send.mock.calls[0]![0];156+    expect(arg.ok).toBe(true);157+    expect(arg.data.maxLength).toBe(32);158+  });159+160+  // PASSKEY_PRO_GATE surfaces the upgrade prompt (State 11).161+  it("PASSKEY_PRO_GATE → showPasskeyProGate called", () => {162+    dispatch({ type: "PASSKEY_PRO_GATE" });163+    expect(showPasskeyProGate).toHaveBeenCalled();164+  });165+166+  // T-M09: an unknown message type falls through harmlessly — no throw, no167+  // unhandled rejection.168+  it("T-M09: unknown message type → handled gracefully", () => {169+    expect(() => dispatch({ type: "TOTALLY_UNKNOWN_MESSAGE" })).not.toThrow();170+    expect(showSelector).not.toHaveBeenCalled();171+  });172+173+  // T-M10: a foreign-extension sender (sender.id mismatch) is ignored entirely —174+  // a security boundary, not just robustness.175+  it("T-M10: message from a foreign sender id is ignored", () => {176+    const send = dispatch({ type: "PING" }, { id: "some-other-extension" });177+    expect(send).not.toHaveBeenCalled();178+  });179+});180+181+// Addendum 2 (T-M11–T-M13, proposed showTotpCopyToast) lives in182+// content.totp-toast.addendum.test.ts, which imports the REAL inject.ts so it183+// can detect whether the proposed export exists. It cannot be probed here184+// because this file mocks ./inject (and the mock throws on unknown exports).

view file

src/content/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 "./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

src/content/content.save.test.ts+136 −0
@@ -0,0 +1,136 @@1+// @vitest-environment jsdom2+//3+// Test File 9 — save.ts save-on-submit detection (T-SV01–T-SV10).4+//5+// setupSaveDetection installs a document submit listener that scrapes the6+// submitted username/password, looks up a matching vault entry7+// (AUTOFILL_QUERY → GET_ENTRY_FOR_FILL), and shows a save/update banner unless8+// the credentials already match. The banner itself is a CLOSED shadow root, and9+// its Save/Update buttons (which send ADD_ENTRY/UPDATE_ENTRY) are unreachable10+// from outside — so those acceptance flows are routed to the e2e. What we pin11+// here is the decision: does the banner mount, and is the lookup correct?12+13+import "./_setup";14+import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";15+import { setupSaveDetection } from "./save";16+import { resetChromeMock, setMessageResponder, sentMessages, makeInput } from "./_setup";17+18+// Mounted save/update banner host (top-right, max z-index, closed shadow).19+function bannerHost(): HTMLElement | null {20+  return document.body.querySelector<HTMLElement>('div[style*="2147483647"]');21+}22+23+// Build and submit a form with the given fields; returns after the async24+// save-detection chain settles.25+async function submitForm(fields: { user?: string; pass?: string; type?: string }): Promise<void> {26+  const form = document.createElement("form");27+  document.body.appendChild(form);28+  if (fields.user !== undefined) {29+    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, form);30+    u.value = fields.user;31+  }32+  if (fields.pass !== undefined) {33+    const p = makeInput({ type: fields.type ?? "password", name: "password" }, form);34+    p.value = fields.pass;35+  }36+  form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));37+  await new Promise((r) => setTimeout(r, 30)); // let the AUTOFILL_QUERY/GET_ENTRY roundtrip settle38+}39+40+beforeAll(() => {41+  setupSaveDetection(); // one document submit listener for the whole file42+});43+44+beforeEach(() => {45+  document.body.innerHTML = "";46+  resetChromeMock();47+});48+afterEach(() => {49+  document.body.innerHTML = "";50+});51+52+describe("T-SV01, T-SV03: banner mounts for new and changed credentials", () => {53+  // T-SV01: submitting credentials with no matching vault entry → save banner.54+  it("T-SV01: new credentials → save banner mounts", async () => {55+    setMessageResponder((msg) => (msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null }));56+    await submitForm({ user: "alice", pass: "s3cret" });57+    expect(bannerHost()).not.toBeNull();58+  });59+60+  // T-SV03: submitting a changed password for an existing entry → update banner.61+  it("T-SV03: changed password for an existing entry → banner mounts (update)", async () => {62+    setMessageResponder((msg) => {63+      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };64+      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old-password" } };65+      return { ok: true, data: null };66+    });67+    await submitForm({ user: "alice", pass: "new-password" });68+    expect(bannerHost()).not.toBeNull();69+  });70+});71+72+describe("T-SV02, T-SV04, T-SV05: no banner cases", () => {73+  // T-SV02: the submitted password already matches the saved entry → no banner.74+  it("T-SV02: credentials already saved (password matches) → no banner", async () => {75+    setMessageResponder((msg) => {76+      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };77+      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "s3cret" } };78+      return { ok: true, data: null };79+    });80+    await submitForm({ user: "alice", pass: "s3cret" });81+    expect(bannerHost()).toBeNull();82+  });83+84+  // T-SV04: a search form has no password field — save detection ignores it and85+  // never even runs the vault lookup.86+  it("T-SV04: search form (no password) → no banner, no vault lookup", async () => {87+    setMessageResponder(() => ({ ok: true, data: [] }));88+    const form = document.createElement("form");89+    document.body.appendChild(form);90+    const q = makeInput({ type: "search", name: "q" }, form);91+    q.value = "kittens";92+    form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));93+    await new Promise((r) => setTimeout(r, 30));94+    expect(bannerHost()).toBeNull();95+    expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false);96+  });97+98+  // T-SV05: an empty password short-circuits before any lookup.99+  it("T-SV05: empty password → no banner, no vault lookup", async () => {100+    setMessageResponder(() => ({ ok: true, data: [] }));101+    await submitForm({ user: "alice", pass: "" });102+    expect(bannerHost()).toBeNull();103+    expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false);104+  });105+});106+107+describe("T-SV10: domain-matched lookup (MV-1 surface)", () => {108+  // T-SV10: the lookup queries AUTOFILL_QUERY with the page domain, then fetches109+  // the full entry by the returned id via GET_ENTRY_FOR_FILL.110+  it("T-SV10: sends AUTOFILL_QUERY{domain} then GET_ENTRY_FOR_FILL{id}", async () => {111+    setMessageResponder((msg) => {112+      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };113+      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old" } };114+      return { ok: true, data: null };115+    });116+    await submitForm({ user: "alice", pass: "new" });117+118+    const query = sentMessages.find((m) => m.type === "AUTOFILL_QUERY");119+    expect(query?.payload?.domain).toBeTruthy();120+    const fetch = sentMessages.find((m) => m.type === "GET_ENTRY_FOR_FILL");121+    expect(fetch?.payload?.id).toBe("e1");122+  });123+});124+125+// ── Closed-shadow / lifecycle boundaries (T-SV06–T-SV09) ─────────────────────126+describe("T-SV06–T-SV09: documented boundaries", () => {127+  // The Save/Dismiss buttons and the homograph warning render inside the128+  // banner's CLOSED shadow root; the accept/dismiss flows (which send129+  // ADD_ENTRY/UPDATE_ENTRY), the popup-lifecycle suppression, and the homograph130+  // warning copy are all verified through the Playwright e2e. See131+  // INTEGRATION-CONTRACT.md.132+  it.todo("T-SV06: user accepts the banner → ADD_ENTRY sent (closed shadow → e2e)");133+  it.todo("T-SV07: user dismisses the banner → nothing saved (closed shadow → e2e)");134+  it.todo("T-SV08: banner does not reappear after popup lifecycle break (e2e)");135+  it.todo("T-SV09: homograph warning shown for non-ASCII domain (closed shadow → e2e)");136+});

view file

src/content/content.selector-ui.test.ts+107 −0
@@ -0,0 +1,107 @@1+// @vitest-environment jsdom2+//3+// Test File 5 — inject.ts showAutofillSelector (T-I01–T-I16).4+//5+// IMPORTANT ARCHITECTURE NOTE: showAutofillSelector mounts its UI inside a6+// CLOSED shadow root (host.attachShadow({ mode: "closed" })). Everything the7+// brief wants to assert about the selector's interior — the entry list, arrow/8+// Enter/Escape keyboard navigation, the search filter, the lazy GET_ENTRY load,9+// the TOTP chip, focus-trapping, above/below placement, isTrusted gating — lives10+// behind that closed boundary and is, by design, unreachable from any external11+// script (including this test and even a page-context Playwright script).12+//13+// So this file asserts the ONLY externally-observable contract: the selector14+// mounts a host into the document and the returned dismiss function tears it15+// down. The interior behaviors are marked it.todo and routed to the Playwright16+// e2e, which verifies them through their effects (fields getting filled, the17+// host disappearing) rather than by reading the closed tree. The recommendation18+// to add a test-only "open shadow" seam is recorded in INTEGRATION-CONTRACT.md.19+20+import "./_setup"; // chrome mock + ResizeObserver polyfill21+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";22+import { showAutofillSelector } from "./inject";23+import { makeInput } from "./_setup";24+25+const ENTRIES = [26+  { id: "e1", title: "Acme", username: "[email protected]" },27+  { id: "e2", title: "Acme Admin", username: "[email protected]" },28+];29+30+// The host is a position:fixed div with the max z-index; closed shadow means we31+// can only see the host, never its contents.32+function selectorHost(): HTMLElement | null {33+  return document.body.querySelector<HTMLElement>('div[style*="2147483647"]');34+}35+36+beforeEach(() => {37+  document.body.innerHTML = "";38+});39+afterEach(() => {40+  document.body.innerHTML = "";41+});42+43+describe("T-I01: mount + dismiss (the observable contract)", () => {44+  // T-I01: opening the selector mounts a host into the document and returns a45+  // dismiss function.46+  it("T-I01: showAutofillSelector mounts a host and returns a dismiss fn", () => {47+    const anchor = makeInput({ type: "password" });48+    const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());49+    expect(typeof dismiss).toBe("function");50+    expect(selectorHost()).not.toBeNull();51+    dismiss();52+  });53+54+  // Dismiss tears the host back down — the closed shadow goes with it. Teardown55+  // is animated: dismiss() schedules host.remove() ~130ms later, so we wait it56+  // out rather than asserting synchronously.57+  it("T-I05/T-I06 (teardown half): the returned dismiss removes the host", async () => {58+    const anchor = makeInput({ type: "password" });59+    const dismiss = showAutofillSelector(anchor, ENTRIES, vi.fn());60+    expect(selectorHost()).not.toBeNull();61+    dismiss();62+    await new Promise((r) => setTimeout(r, 180));63+    expect(selectorHost()).toBeNull();64+  });65+66+  // The host mounts even in locked mode (the unlock prompt is also closed-shadow).67+  it("T-I01b: locked-mode open also mounts a host", () => {68+    const anchor = makeInput({ type: "password" });69+    const dismiss = showAutofillSelector(anchor, [], vi.fn(), { locked: true });70+    expect(selectorHost()).not.toBeNull();71+    dismiss();72+  });73+74+  // Re-opening dismisses any previous host the caller forgot — but the caller75+  // (index.ts) owns currentDismiss, so here we just confirm two opens leave two76+  // hosts unless dismissed, proving each open is independent.77+  it("T-I01c: each open creates its own host (caller owns single-instance policy)", () => {78+    const anchor = makeInput({ type: "password" });79+    const d1 = showAutofillSelector(anchor, ENTRIES, vi.fn());80+    const d2 = showAutofillSelector(anchor, ENTRIES, vi.fn());81+    expect(document.body.querySelectorAll('div[style*="2147483647"]').length).toBe(2);82+    d1();83+    d2();84+  });85+});86+87+// ── Closed-shadow internals → Playwright e2e (T-I02–T-I16) ───────────────────88+// Each of these requires reading or interacting with the closed shadow tree.89+// They are real test intents, documented here and implemented in90+// content.real-page.e2e.test.ts where behavior is verified through effects.91+describe("T-I02–T-I16: interior behavior (closed shadow → e2e)", () => {92+  it.todo("T-I02: ArrowDown moves selection to the next entry");93+  it.todo("T-I03: ArrowUp wraps from first to last entry");94+  it.todo("T-I04: Enter selects the focused entry, fills, and closes");95+  it.todo("T-I05: Escape closes the selector and fires the dismissal lock");96+  it.todo("T-I06: click outside closes the selector and fires the dismissal lock");97+  it.todo("T-I07: typing in the search box filters entries by title/domain");98+  it.todo("T-I08: search lazily fires GET_ENTRY_FOR_FILL only on a user gesture (MV-2)");99+  it.todo("T-I09: empty search results show the 'no matches' state");100+  it.todo("T-I10: selector repositions on window scroll (stays anchored)");101+  it.todo("T-I11: selector repositions on window resize");102+  it.todo("T-I12: selector renders above the field when space below is insufficient");103+  it.todo("T-I13: an entry with TOTP shows a live-countdown chip");104+  it.todo("T-I14: an entry without TOTP shows no chip");105+  it.todo("T-I15: keyboard nav is trapped — Tab cycles entries, does not escape to page");106+  it.todo("T-I16: isTrusted — synthetic keyboard events do not trigger fill");107+});

view file

src/content/content.totp-toast.addendum.test.ts+59 −0
@@ -0,0 +1,59 @@1+// @vitest-environment jsdom2+//3+// Addendum 2 — TOTP auto-copy toast (T-M11–T-M13), proposed feature.4+//5+// Today, when Peach auto-copies a TOTP code it shows the generic "2FA code6+// copied" toast (showToast in index.ts). The addendum asks for a dedicated7+// showTotpCopyToast(secondsUntilClear) that renders a passkey-family toast8+// ("2FA code copied / clears in 30s") and auto-dismisses after 3s, resetting its9+// timer if a second copy lands while it is visible.10+//11+// showTotpCopyToast does not exist yet. This file imports the REAL inject.ts and12+// activates the spec the moment the export appears — so it is an executable13+// contract, not a stub that always passes. See INTEGRATION-CONTRACT.md.14+15+import "./_setup"; // installs the chrome mock inject.ts touches at import time16+import { describe, expect, it, vi } from "vitest";17+import * as inject from "./inject";18+19+const showTotpCopyToast = (inject as unknown as {20+  showTotpCopyToast?: (secondsUntilClear: number) => void;21+}).showTotpCopyToast;22+23+describe.skipIf(typeof showTotpCopyToast !== "function")(24+  "Addendum 2 — proposed showTotpCopyToast (requires inject.ts change)",25+  () => {26+    const toast = showTotpCopyToast!;27+28+    it("T-M11: renders a toast showing the configured clear time", () => {29+      document.body.innerHTML = "";30+      expect(() => toast(30)).not.toThrow();31+      expect(document.body.textContent ?? "").toMatch(/clears in 30/i);32+    });33+34+    it("T-M12: a second copy while showing resets, leaving a single toast", () => {35+      document.body.innerHTML = "";36+      toast(30);37+      toast(30);38+      const toasts = document.querySelectorAll('[data-peach-toast], .peach-totp-toast');39+      expect(toasts.length).toBeLessThanOrEqual(1);40+    });41+42+    it("T-M13: auto-dismisses after ~3s", () => {43+      document.body.innerHTML = "";44+      vi.useFakeTimers();45+      toast(30);46+      vi.advanceTimersByTime(3500);47+      vi.useRealTimers();48+      expect(document.body.textContent ?? "").not.toMatch(/2FA code copied/i);49+    });50+  }51+);52+53+// Guard so the file always has at least one executing assertion even before the54+// feature exists — documents the current state explicitly.55+describe("Addendum 2 status", () => {56+  it("documents that showTotpCopyToast is not yet implemented", () => {57+    expect(typeof showTotpCopyToast).toBe("undefined");58+  });59+});

view file