2a7932644ca9

chore: version bump 3.9.9 → 4.0.0 (manifest.json + manifest.firefox.json + package.json) + build Chrome/Firefox/Mobile; trash now-integrated AUTOFILL-TESTS draft files (kept INTEGRATION-CONTRACT.md as reference). Final: tsc no new errors (27 pre-existing), vitest 117 files/3474 passed/0 skipped/40 todo (v4.0.0)

time
2026-06-12 14:04 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
autofill tests integration
parent
ab5c9a638d4b
19 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/drafts/AUTOFILL-TESTS/_setup.ts+0 −272src/drafts/AUTOFILL-TESTS/_smoke.test.ts+0 −25src/drafts/AUTOFILL-TESTS/content.detect.test.ts+0 −390src/drafts/AUTOFILL-TESTS/content.dismissal.test.ts+0 −268src/drafts/AUTOFILL-TESTS/content.field-icon.test.ts+0 −128src/drafts/AUTOFILL-TESTS/content.field-interaction.test.ts+0 −194src/drafts/AUTOFILL-TESTS/content.fill.test.ts+0 −198src/drafts/AUTOFILL-TESTS/content.initial-scan.test.ts+0 −144src/drafts/AUTOFILL-TESTS/content.interaction.test.ts+0 −115src/drafts/AUTOFILL-TESTS/content.message-routing.test.ts+0 −184src/drafts/AUTOFILL-TESTS/content.policy.test.ts+0 −126src/drafts/AUTOFILL-TESTS/content.real-page.e2e.spec.ts+0 −191src/drafts/AUTOFILL-TESTS/content.save.test.ts+0 −136src/drafts/AUTOFILL-TESTS/content.selector-ui.test.ts+0 −107src/drafts/AUTOFILL-TESTS/content.totp-toast.addendum.test.ts+0 −59src/drafts/AUTOFILL-TESTS/login-form.fixture.html+0 −32
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.9.9",5+  "version": "4.0.0",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.9.9",4+  "version": "4.0.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.9.9",3+  "version": "4.0.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/drafts/AUTOFILL-TESTS/_setup.ts+0 −272
@@ -1,272 +0,0 @@1-// Shared test harness for the AUTOFILL-TESTS draft suite.2-//3-// Two responsibilities:4-//   1. SIDE EFFECT — installing a chrome extension API mock onto globalThis the5-//      moment this module is evaluated. index.ts and inject.ts touch6-//      `chrome.runtime.*` at module-evaluation time (inject.ts line 41 fires a7-//      GET_SETTINGS call; index.ts registers a runtime.onMessage listener and8-//      runs an async init IIFE). ES module imports are evaluated in source9-//      order, so a test file that does `import "./_setup"` BEFORE10-//      `import "../../content/index"` guarantees chrome exists first.11-//   2. HELPERS — DOM factories and a jsdom visibility shim. jsdom reports a 0x012-//      getBoundingClientRect for every element, which would make detect.ts's13-//      isVisible() return false for everything. makeFieldVisible() patches the14-//      rect the same way the existing content tests do.15-//16-// Nothing here mocks the functions under test — only the browser/extension17-// boundary. Tests drive the real exported functions with controlled DOM input.18-19-import { vi } from "vitest";20-21-// ── Chrome message plumbing ──────────────────────────────────────────────────22-// A test can install its own responder to control what the background "replies"23-// for a given message, and inspect every message that was sent.24-25-export type MessageResponder = (msg: any) => any;26-27-let messageResponder: MessageResponder = (msg) => {28-  // Sane defaults so module-load-time calls (GET_SETTINGS, GET_VAULT_STATUS)29-  // resolve to a harmless shape instead of undefined.30-  switch (msg?.type) {31-    case "GET_SETTINGS":32-      return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } };33-    case "GET_VAULT_STATUS":34-      return { ok: true, data: { unlocked: false } };35-    default:36-      return { ok: true, data: null };37-  }38-};39-40-/** Messages sent via chrome.runtime.sendMessage, in order. */41-export const sentMessages: any[] = [];42-/** Listeners registered via chrome.runtime.onMessage.addListener. */43-export const messageListeners: Array<44-  (msg: any, sender: any, sendResponse: (r?: any) => void) => unknown45-> = [];46-47-/** Override the reply for subsequent sendMessage calls. */48-export function setMessageResponder(fn: MessageResponder): void {49-  messageResponder = fn;50-}51-52-/** Deliver a message to every registered onMessage listener (e.g. VAULT_UNLOCKED). */53-export function dispatchRuntimeMessage(54-  msg: any,55-  sender: any = { id: "peach-test-extension-id" }56-): Array<unknown> {57-  const responses: unknown[] = [];58-  for (const listener of messageListeners) {59-    responses.push(listener(msg, sender, (r?: any) => responses.push(r)));60-  }61-  return responses;62-}63-64-const sendMessage = vi.fn((msg: any, maybeCb?: (resp: any) => void) => {65-  sentMessages.push(msg);66-  const result = messageResponder(msg);67-  const resolved = Promise.resolve(result);68-  if (typeof maybeCb === "function") {69-    resolved.then((r) => maybeCb(r));70-    return undefined as unknown as Promise<any>;71-  }72-  return resolved;73-});74-75-const sessionStore = new Map<string, unknown>();76-77-const chromeMock = {78-  runtime: {79-    id: "peach-test-extension-id",80-    sendMessage,81-    getURL: (path: string) => `chrome-extension://peach-test/${path}`,82-    onMessage: {83-      addListener: (fn: any) => messageListeners.push(fn),84-      removeListener: (fn: any) => {85-        const i = messageListeners.indexOf(fn);86-        if (i >= 0) messageListeners.splice(i, 1);87-      },88-    },89-    lastError: undefined as undefined | { message: string },90-  },91-  storage: {92-    session: {93-      get: (key: string) =>94-        Promise.resolve(sessionStore.has(key) ? { [key]: sessionStore.get(key) } : {}),95-      set: (obj: Record<string, unknown>) => {96-        for (const [k, v] of Object.entries(obj)) sessionStore.set(k, v);97-        return Promise.resolve();98-      },99-    },100-    local: {101-      get: () => Promise.resolve({}),102-      set: () => Promise.resolve(),103-    },104-  },105-};106-107-// Install on globalThis so `chrome` is a resolvable free identifier in the108-// modules under test, exactly as it is inside a real content script.109-(globalThis as any).chrome = chromeMock;110-111-// jsdom ships no ResizeObserver; inject.ts's observeAnchorPosition and the112-// selector's height-animation both construct one at call time. A no-op stub is113-// enough — the tests assert on injected DOM, not on layout reflow.114-if (typeof (globalThis as any).ResizeObserver === "undefined") {115-  (globalThis as any).ResizeObserver = class {116-    observe(): void {}117-    unobserve(): void {}118-    disconnect(): void {}119-  };120-}121-122-// navigator.clipboard.writeText is used by the TOTP/auto-copy paths. jsdom does123-// not provide a writable clipboard, so define a spy-able one.124-export const clipboardWrite = vi.fn(() => Promise.resolve());125-if (!("clipboard" in navigator)) {126-  Object.defineProperty(navigator, "clipboard", {127-    configurable: true,128-    value: { writeText: clipboardWrite },129-  });130-} else {131-  Object.defineProperty(navigator.clipboard, "writeText", {132-    configurable: true,133-    value: clipboardWrite,134-  });135-}136-137-/** Reset all captured state between tests. Does NOT clear registered listeners138- *  (those are installed once at module import and must survive across tests). */139-export function resetChromeMock(): void {140-  sentMessages.length = 0;141-  sessionStore.clear();142-  sendMessage.mockClear();143-  clipboardWrite.mockClear();144-  messageResponder = (msg) => {145-    switch (msg?.type) {146-      case "GET_SETTINGS":147-        return { ok: true, data: { accentColor: "#ff9776", theme: "dark" } };148-      case "GET_VAULT_STATUS":149-        return { ok: true, data: { unlocked: false } };150-      default:151-        return { ok: true, data: null };152-    }153-  };154-}155-156-// ── DOM helpers ──────────────────────────────────────────────────────────────157-158-const VISIBLE_RECT = {159-  width: 160,160-  height: 32,161-  top: 10,162-  left: 10,163-  right: 170,164-  bottom: 42,165-  x: 10,166-  y: 10,167-  toJSON: () => ({}),168-} as const;169-170-/** Patch an element so detect.ts's isVisible() treats it as on-screen.171- *  jsdom returns a 0x0 rect for everything; without this every field reads172- *  as invisible and detection returns nothing. */173-export function makeFieldVisible(el: HTMLElement): void {174-  Object.defineProperty(el, "getBoundingClientRect", {175-    configurable: true,176-    value: () => ({ ...VISIBLE_RECT }),177-  });178-}179-180-/** Create a visible <input> with the given attributes appended to the body. */181-export function makeInput(182-  attrs: Record<string, string> = {},183-  parent: HTMLElement = document.body184-): HTMLInputElement {185-  const input = document.createElement("input");186-  for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);187-  parent.appendChild(input);188-  makeFieldVisible(input);189-  return input;190-}191-192-/** Standard username + current-password login form. */193-export function createLoginForm(): {194-  form: HTMLFormElement;195-  username: HTMLInputElement;196-  password: HTMLInputElement;197-} {198-  const form = document.createElement("form");199-  document.body.appendChild(form);200-  const username = makeInput(201-    { type: "text", name: "username", autocomplete: "username" },202-    form203-  );204-  const password = makeInput(205-    { type: "password", name: "password", autocomplete: "current-password" },206-    form207-  );208-  const button = document.createElement("button");209-  button.type = "submit";210-  button.textContent = "Log in";211-  form.appendChild(button);212-  return { form, username, password };213-}214-215-/** Email-first (identifier) form: single email field, no password. */216-export function createEmailFirstForm(): {217-  form: HTMLFormElement;218-  email: HTMLInputElement;219-} {220-  const form = document.createElement("form");221-  document.body.appendChild(form);222-  const email = makeInput(223-    { type: "email", name: "email", autocomplete: "email" },224-    form225-  );226-  const button = document.createElement("button");227-  button.type = "submit";228-  button.textContent = "Next";229-  form.appendChild(button);230-  return { form, email };231-}232-233-/** Signup form: username + new-password + confirm. */234-export function createSignupForm(): {235-  form: HTMLFormElement;236-  username: HTMLInputElement;237-  password: HTMLInputElement;238-  confirm: HTMLInputElement;239-} {240-  const form = document.createElement("form");241-  document.body.appendChild(form);242-  const username = makeInput({ type: "text", name: "new-username" }, form);243-  const password = makeInput(244-    { type: "password", name: "new-password", autocomplete: "new-password" },245-    form246-  );247-  const confirm = makeInput(248-    { type: "password", name: "confirm-password", autocomplete: "new-password" },249-    form250-  );251-  const button = document.createElement("button");252-  button.type = "submit";253-  button.textContent = "Create account";254-  form.appendChild(button);255-  return { form, username, password, confirm };256-}257-258-/** Split-OTP form: `count` adjacent single-char numeric inputs. */259-export function createSplitOtpForm(count = 6): {260-  form: HTMLFormElement;261-  boxes: HTMLInputElement[];262-} {263-  const form = document.createElement("form");264-  document.body.appendChild(form);265-  const boxes: HTMLInputElement[] = [];266-  for (let i = 0; i < count; i++) {267-    boxes.push(268-      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, form)269-    );270-  }271-  return { form, boxes };272-}

view file at the parent

src/drafts/AUTOFILL-TESTS/_smoke.test.ts+0 −25
@@ -1,25 +0,0 @@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 { beforeAll, 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("../../content/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("../../content/inject");22-    expect(typeof mod.showAutofillSelector).toBe("function");23-    expect(typeof mod.injectFieldIcon).toBe("function");24-  });25-});

view file at the parent

src/drafts/AUTOFILL-TESTS/content.detect.test.ts+0 −390
@@ -1,390 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 7 — detect.ts field detection (T-DT01–T-DT21).4-// Plus Addendum 1 — split-OTP grouping.5-//6-// detect.ts is the foundation of the whole autofill layer: every gate in7-// index.ts asks it "is this a login/signup/email-first/card/totp form?". A8-// false positive injects an icon onto a search box; a false negative means the9-// user's saved login never offers to fill. These tests pin the classification10-// boundaries against real HTML structures that production sites emit.11-12-import { beforeEach, describe, expect, it } from "vitest";13-14-import {15-  detectCardForm,16-  detectLoginFields,17-  findOTPDigitBoxGroup,18-  isAnyCredentialForm,19-  isEmailFirstForm,20-  isLoginForm,21-  isSignupForm,22-  isTOTPField,23-  isVisible,24-} from "../../content/detect";25-import { makeFieldVisible, makeInput } from "./_setup";26-27-// detect.ts's querySelectorAllDeep re-queries every ancestor element, so a field28-// nested under html>body>form appears 3× in detectLoginFields() output. The29-// committed detect.test.ts copes the same way we do here: assert detection by30-// *membership* (fields.some) and gate "is this page fillable?" through31-// isAnyCredentialForm (the exact predicate index.ts uses — true via the signup32-// branch even when duplication inflates the password count). The precise33-// login-vs-signup classifier contract is exercised on clean, hand-built arrays.34-35-beforeEach(() => {36-  document.body.innerHTML = "";37-});38-39-// Build a form, append it, and return it. Children are added via makeInput so40-// every field gets a non-zero bounding rect (jsdom defaults to 0×0, which41-// detect.ts's isVisible() would reject).42-function form(): HTMLFormElement {43-  const f = document.createElement("form");44-  document.body.appendChild(f);45-  return f;46-}47-48-describe("T-DT01–T-DT03, T-DT06: form-shape classification", () => {49-  // T-DT01: a text + current-password pair is the canonical login form. If this50-  // regresses, saved logins stop offering on the most common form on the web.51-  it("T-DT01: text + password → detected as a fillable login form", () => {52-    const f = form();53-    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);54-    const p = makeInput({ type: "password", name: "password", autocomplete: "current-password" }, f);55-    const fields = detectLoginFields();56-    expect(fields.some((x) => x.el === u && x.type === "username")).toBe(true);57-    expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);58-    // The gate index.ts actually consults:59-    expect(isAnyCredentialForm(fields)).toBe(true);60-    // Classifier contract on a clean field set:61-    expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);62-  });63-64-  // T-DT02: two password fields (password + confirm) is the signature of signup.65-  // Misclassifying it as login would offer the saved-password selector on an66-  // account-creation form.67-  it("T-DT02: text + new-password + confirm → isSignupForm true, isLoginForm false", () => {68-    const f = form();69-    makeInput({ type: "text", name: "username" }, f);70-    makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);71-    makeInput({ type: "password", name: "confirm", autocomplete: "new-password" }, f);72-    const fields = detectLoginFields();73-    expect(isSignupForm(fields)).toBe(true);74-    expect(isLoginForm(fields)).toBe(false);75-  });76-77-  // T-DT03: email-only step with an auth-intent submit button is the Google/MS78-  // "enter your email first" pattern. Requires an auth signal so newsletter79-  // boxes don't match.80-  it("T-DT03: email-only form with auth submit → isEmailFirstForm true", () => {81-    const f = form();82-    makeInput({ type: "email", name: "email", autocomplete: "email" }, f);83-    const btn = document.createElement("button");84-    btn.type = "submit";85-    btn.textContent = "Next";86-    f.appendChild(btn);87-    const fields = detectLoginFields();88-    expect(isEmailFirstForm(fields)).toBe(true);89-  });90-91-  // T-DT06: a search box (single text input, search submit) must NOT read as a92-  // login form. False positive here is the classic "icon on the search bar" bug.93-  it("T-DT06: search form → isLoginForm false", () => {94-    const f = form();95-    makeInput({ type: "search", name: "q", placeholder: "Search" }, f);96-    const fields = detectLoginFields();97-    expect(isLoginForm(fields)).toBe(false);98-    expect(isSignupForm(fields)).toBe(false);99-  });100-});101-102-describe("T-DT04: TOTP field detection", () => {103-  // T-DT04: autocomplete=one-time-code is the unambiguous TOTP marker. The TOTP104-  // selector path keys off this; losing it breaks 2FA autofill.105-  it("T-DT04: input[autocomplete=one-time-code] → isTotpField true", () => {106-    const f = form();107-    const otp = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);108-    expect(isTOTPField(otp)).toBe(true);109-  });110-111-  it("T-DT04b: a plain password field is not a TOTP field", () => {112-    const f = form();113-    const pw = makeInput({ type: "password", name: "password" }, f);114-    expect(isTOTPField(pw)).toBe(false);115-  });116-});117-118-describe("T-DT05: credit-card form detection", () => {119-  // T-DT05: cc-number + cc-exp + cc-csc autocomplete tokens → card form. detect120-  // exposes detectCardForm (returns the field set or null), not a boolean.121-  it("T-DT05: cardnumber + expiry + cvc → detectCardForm returns the field set", () => {122-    const f = form();123-    const number = makeInput({ autocomplete: "cc-number", name: "cardnumber" }, f);124-    const exp = makeInput({ autocomplete: "cc-exp", name: "exp" }, f);125-    const cvc = makeInput({ autocomplete: "cc-csc", name: "cvc" }, f);126-    const card = detectCardForm();127-    expect(card).not.toBeNull();128-    expect(card!.number).toBe(number);129-    expect(card!.expiryCombo).toBe(exp);130-    expect(card!.cvc).toBe(cvc);131-  });132-133-  it("T-DT05b: a login form is not a card form", () => {134-    const f = form();135-    makeInput({ type: "text", name: "username" }, f);136-    makeInput({ type: "password", name: "password" }, f);137-    expect(detectCardForm()).toBeNull();138-  });139-});140-141-describe("T-DT07–T-DT09: isVisible branches", () => {142-  // isVisible gates every detection path. Each of its three rejection reasons143-  // (zero area, display:none, opacity:0) must independently return false, or144-  // off-screen/hidden honeypot fields would receive icons.145-146-  // T-DT07: a zero-area element (jsdom default, models an unrendered field).147-  it("T-DT07: zero-size rect → isVisible false", () => {148-    const el = makeInput({ type: "text" });149-    Object.defineProperty(el, "getBoundingClientRect", {150-      configurable: true,151-      value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),152-    });153-    expect(isVisible(el)).toBe(false);154-  });155-156-  // T-DT08: display:none.157-  it("T-DT08: display:none → isVisible false", () => {158-    const el = makeInput({ type: "text" });159-    el.style.display = "none";160-    expect(isVisible(el)).toBe(false);161-  });162-163-  // T-DT09: opacity:0 (a common "hidden but laid out" honeypot technique).164-  it("T-DT09: opacity:0 → isVisible false", () => {165-    const el = makeInput({ type: "text" });166-    el.style.opacity = "0";167-    expect(isVisible(el)).toBe(false);168-  });169-170-  it("T-DT09b: a normally-laid-out field → isVisible true (control)", () => {171-    const el = makeInput({ type: "text" });172-    expect(isVisible(el)).toBe(true);173-  });174-});175-176-describe("T-DT10: deep (shadow DOM) detection", () => {177-  // T-DT10: web-component login forms render fields inside a shadow root.178-  // detectLoginFields walks shadow roots via querySelectorAllDeep; a regression179-  // to a flat querySelectorAll would miss every shadow-DOM login.180-  it("T-DT10: login fields inside an open shadow root are detected", () => {181-    const host = document.createElement("div");182-    document.body.appendChild(host);183-    const root = host.attachShadow({ mode: "open" });184-    // Real web-component login forms wrap fields in a container element inside185-    // the shadow root; querySelectorAllDeep collects inputs via each element's186-    // querySelectorAll, so the wrapper is what surfaces them.187-    const wrapper = document.createElement("div");188-    root.appendChild(wrapper);189-    const user = document.createElement("input");190-    user.type = "text";191-    user.setAttribute("name", "username");192-    user.setAttribute("autocomplete", "username");193-    const pass = document.createElement("input");194-    pass.type = "password";195-    pass.setAttribute("autocomplete", "current-password");196-    wrapper.append(user, pass);197-    makeFieldVisible(user);198-    makeFieldVisible(pass);199-200-    const fields = detectLoginFields();201-    expect(fields.some((f) => f.type === "password")).toBe(true);202-    expect(isAnyCredentialForm(fields)).toBe(true);203-  });204-});205-206-describe("T-DT11–T-DT12: PCH-01-025 CSS-special-char id resilience", () => {207-  // PCH-01-025: ids can contain CSS-special characters. detect.ts escapes them208-  // with CSS.escape AND wraps the label lookup in try/catch so a single weird id209-  // can never abort detection for the whole page.210-  //211-  // NOTE on the environment: jsdom's selector engine (nwsapi) actually THROWS on212-  // CSS.escape's output for these ids, so the *label-resolution* benefit cannot213-  // be observed here — it is verified in the Playwright e2e. What we pin in214-  // jsdom is the resilience invariant: a field whose id is hostile to215-  // querySelector must not throw out of detectLoginFields, and the other fields216-  // on the page must still be found. Reverting the try/catch makes T-DT12b throw.217-  it("T-DT11: id='user[email]' does not abort detection; the password is still found", () => {218-    const f = form();219-    makeInput({ type: "text", id: "user[email]", name: "user[email]" }, f);220-    makeInput({ type: "password", autocomplete: "current-password" }, f);221-    let fields: ReturnType<typeof detectLoginFields> = [];222-    expect(() => { fields = detectLoginFields(); }).not.toThrow();223-    expect(fields.some((x) => x.type === "password")).toBe(true);224-  });225-226-  it("T-DT12: id=\"it's-me\" does not abort detection", () => {227-    const f = form();228-    makeInput({ type: "text", id: "it's-me", name: "identifier" }, f);229-    makeInput({ type: "password", autocomplete: "current-password" }, f);230-    expect(() => detectLoginFields()).not.toThrow();231-  });232-233-  // T-DT12b: an id containing a double-quote genuinely throws inside a raw234-  // `label[for="..."]` selector. Only the try/catch keeps detection alive — this235-  // case fails the moment the catch is removed.236-  it("T-DT12b: id with an embedded double-quote does not abort detection", () => {237-    const f = form();238-    makeInput({ type: "text", id: 'a"quote', name: "weird" }, f);239-    makeInput({ type: "password", autocomplete: "current-password" }, f);240-    let fields: ReturnType<typeof detectLoginFields> = [];241-    expect(() => { fields = detectLoginFields(); }).not.toThrow();242-    expect(fields.some((x) => x.type === "password")).toBe(true);243-  });244-});245-246-describe("T-DT13–T-DT15: label / placeholder / aria-label classification", () => {247-  // T-DT13: a <label for=id> with a plain (escape-safe) id classifies an248-  // otherwise-unhinted text input as the username field.249-  it("T-DT13: label[for] resolves the field as username", () => {250-    const f = form();251-    const input = makeInput({ type: "text", id: "login_user" }, f);252-    const label = document.createElement("label");253-    label.setAttribute("for", "login_user");254-    label.textContent = "Username";255-    // Append AFTER the input so the previous-sibling heuristic cannot match —256-    // forcing the for-attribute path to be the one that resolves the label.257-    f.appendChild(label);258-    makeInput({ type: "password", autocomplete: "current-password" }, f);259-    const fields = detectLoginFields();260-    expect(fields.find((x) => x.el === input)?.type).toBe("username");261-  });262-263-  // T-DT14: placeholder text is a real-world classification signal.264-  it("T-DT14: placeholder='Username' → classified username", () => {265-    const f = form();266-    const input = makeInput({ type: "text", placeholder: "Username" }, f);267-    makeInput({ type: "password", autocomplete: "current-password" }, f);268-    const fields = detectLoginFields();269-    expect(fields.find((x) => x.el === input)?.type).toBe("username");270-  });271-272-  // T-DT15: aria-label is authoritative for accessibility-first forms.273-  it("T-DT15: aria-label='Email address' → classified", () => {274-    const f = form();275-    const input = makeInput({ type: "text", "aria-label": "Email address" }, f);276-    makeInput({ type: "password", autocomplete: "current-password" }, f);277-    const fields = detectLoginFields();278-    expect(fields.find((x) => x.el === input)?.type).toBe("username");279-  });280-});281-282-describe("T-DT16–T-DT17: edge form shapes", () => {283-  // T-DT16: many SPA login forms submit via JS and have no submit button. Field284-  // classification must not depend on a button existing.285-  it("T-DT16: form without a submit button is still a fillable login form", () => {286-    const f = form();287-    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);288-    const p = makeInput({ type: "password", autocomplete: "current-password" }, f);289-    const fields = detectLoginFields();290-    expect(fields.some((x) => x.el === u)).toBe(true);291-    expect(fields.some((x) => x.el === p && x.type === "password")).toBe(true);292-    expect(isAnyCredentialForm(fields)).toBe(true);293-    expect(isLoginForm([{ type: "username", el: u }, { type: "password", el: p }])).toBe(true);294-  });295-296-  // T-DT17: change-password forms have old + new + confirm. Three password297-  // fields must read as signup-shaped (>=2 passwords), never as login.298-  it("T-DT17: old + new + confirm passwords → signup-shaped, not login", () => {299-    const f = form();300-    makeInput({ type: "password", name: "old-password", autocomplete: "current-password" }, f);301-    makeInput({ type: "password", name: "new-password", autocomplete: "new-password" }, f);302-    makeInput({ type: "password", name: "confirm-password", autocomplete: "new-password" }, f);303-    const fields = detectLoginFields();304-    expect(isSignupForm(fields)).toBe(true);305-    expect(isLoginForm(fields)).toBe(false);306-  });307-});308-309-// ── Addendum 1 — split-OTP grouping ─────────────────────────────────────────310-// The existing detector is findOTPDigitBoxGroup: it returns the ordered set of311-// single-character boxes (4–10 of them) or null. index.ts uses it to open ONE312-// TOTP selector anchored on the first box instead of icon-spamming all six.313-describe("T-DT18–T-DT21 (existing findOTPDigitBoxGroup behavior)", () => {314-  // T-DT18: a 6-box numeric group resolves to the full ordered set, anchored so315-  // index.ts injects a single icon on box[0] rather than one per box.316-  it("T-DT18: 6× maxlength=1 numeric inputs → group of 6 returned, first is box[0]", () => {317-    const f = form();318-    const boxes: HTMLInputElement[] = [];319-    for (let i = 0; i < 6; i++) boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f));320-    const group = findOTPDigitBoxGroup(boxes[2]!);321-    expect(group).not.toBeNull();322-    expect(group!.length).toBe(6);323-    expect(group![0]).toBe(boxes[0]);324-  });325-326-  // T-DT19: when a real one-time-code field exists, IT is the TOTP target; the327-  // single-char boxes elsewhere are a distinct concern. isTOTPField ignores328-  // maxlength=1 boxes, so the proper field is the one that classifies as TOTP.329-  it("T-DT19: a real one-time-code field classifies as TOTP; maxlength=1 boxes do not", () => {330-    const f = form();331-    const box = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);332-    const real = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f);333-    expect(isTOTPField(real)).toBe(true);334-    expect(isTOTPField(box)).toBe(false);335-  });336-337-  // T-DT21: a group below the 4-box floor is not an OTP group (existing338-  // threshold). Addendum 1 proposes a 3+ threshold for isSplitOtpGroup — see the339-  // skipped block below.340-  it("T-DT21: only 2 single-char boxes → not an OTP group (null)", () => {341-    const f = form();342-    const boxes = [343-      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),344-      makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),345-    ];346-    expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();347-  });348-});349-350-// Addendum 1 PROPOSED API — isSplitOtpGroup(field) with a 3+ threshold and a351-// numeric-only guard so an initials form (3 single-char *alpha* inputs) is not352-// treated as OTP. This function does not exist yet; these tests activate the353-// moment it is exported from detect.ts. They are the executable spec for the354-// addendum. See INTEGRATION-CONTRACT.md.355-import * as detectModule from "../../content/detect";356-const hasIsSplitOtpGroup =357-  typeof (detectModule as unknown as { isSplitOtpGroup?: unknown }).isSplitOtpGroup === "function";358-359-describe.skipIf(!hasIsSplitOtpGroup)("Addendum 1 — proposed isSplitOtpGroup (requires detect.ts change)", () => {360-  const isSplitOtpGroup = (detectModule as unknown as {361-    isSplitOtpGroup: (f: HTMLInputElement) => boolean;362-  }).isSplitOtpGroup;363-364-  it("T-DT18 (proposed): 6 adjacent numeric single-char inputs → true", () => {365-    const f = form();366-    let first: HTMLInputElement | null = null;367-    for (let i = 0; i < 6; i++) {368-      const b = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);369-      first ??= b;370-    }371-    expect(isSplitOtpGroup(first!)).toBe(true);372-  });373-374-  it("T-DT20 (proposed): 3 single-char ALPHA inputs (initials) → false", () => {375-    const f = form();376-    let first: HTMLInputElement | null = null;377-    for (let i = 0; i < 3; i++) {378-      const b = makeInput({ type: "text", maxlength: "1" }, f); // no numeric mode379-      first ??= b;380-    }381-    expect(isSplitOtpGroup(first!)).toBe(false);382-  });383-384-  it("T-DT21 (proposed): a 2-box group is below the 3+ threshold → false", () => {385-    const f = form();386-    const a = makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);387-    makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f);388-    expect(isSplitOtpGroup(a)).toBe(false);389-  });390-});

view file at the parent

src/drafts/AUTOFILL-TESTS/content.dismissal.test.ts+0 −268
@@ -1,268 +0,0 @@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("../../content/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/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 "../../content/index";44-import { showAutofillSelector } from "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.field-icon.test.ts+0 −128
@@ -1,128 +0,0 @@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 "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.field-interaction.test.ts+0 −194
@@ -1,194 +0,0 @@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("../../content/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/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 "../../content/index";41-import { showAutofillSelector, showTOTPSelector } from "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.fill.test.ts+0 −198
@@ -1,198 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 8 — fill.ts credential injection (T-FL01–T-FL12).4-//5-// fill.ts is where credentials actually enter the page. The subtle part is6-// setNativeValue: React/Vue track input values through the native value setter,7-// not direct `.value =` assignment, so fill.ts pulls the prototype setter and8-// dispatches real input+change events. If that breaks, fills look successful to9-// Peach but the site's controlled-input state stays empty — the canonical10-// "autofill did nothing" bug. These tests use real DOM elements, never mocks.11-12-import { beforeEach, describe, expect, it, vi } from "vitest";13-14-import {15-  detectCardNetwork,16-  extractLast4,17-  fillCardForm,18-  fillFields,19-  fillTOTPCode,20-  formatCardNumber,21-  passesLuhn,22-  setNativeValue,23-} from "../../content/fill";24-import { makeInput } from "./_setup";25-26-beforeEach(() => {27-  document.body.innerHTML = "";28-});29-30-describe("T-FL01–T-FL03: fillFields + event dispatch", () => {31-  // T-FL01: the core contract — username and password land in their inputs.32-  it("T-FL01: fills username and password into standard inputs", () => {33-    const u = makeInput({ type: "text" });34-    const p = makeInput({ type: "password" });35-    fillFields(u, p, "alice", "s3cret");36-    expect(u.value).toBe("alice");37-    expect(p.value).toBe("s3cret");38-  });39-40-  // T-FL02: an input event must fire so reactive frameworks re-read the field.41-  it("T-FL02: dispatches an input event on filled fields", () => {42-    const p = makeInput({ type: "password" });43-    const onInput = vi.fn();44-    p.addEventListener("input", onInput);45-    fillFields(null, p, "", "pw");46-    expect(onInput).toHaveBeenCalledTimes(1);47-  });48-49-  // T-FL03: a change event must fire too (some frameworks/validators key on it).50-  it("T-FL03: dispatches a change event on filled fields", () => {51-    const p = makeInput({ type: "password" });52-    const onChange = vi.fn();53-    p.addEventListener("change", onChange);54-    fillFields(null, p, "", "pw");55-    expect(onChange).toHaveBeenCalledTimes(1);56-  });57-});58-59-describe("T-FL04–T-FL05: framework-controlled inputs (setNativeValue)", () => {60-  // T-FL04: React installs its own value setter on the *instance*; setNativeValue61-  // must call the *prototype* setter so React's value tracker observes the change.62-  // We emulate React's tracker by shadowing the instance value descriptor and63-  // asserting the prototype setter still updated the underlying value AND that64-  // the synthetic input event carried the new value.65-  it("T-FL04: setNativeValue bypasses a React-style instance setter via the prototype setter", () => {66-    const el = makeInput({ type: "text" });67-    let trackerSawValue: string | null = null;68-    el.addEventListener("input", () => { trackerSawValue = el.value; });69-70-    // React installs an own `value` accessor on the element whose setter71-    // swallows direct `.value =` assignment (its value-tracker). setNativeValue72-    // must reach the *prototype* setter to update the real value slot. The73-    // getter here delegates to the prototype so reads still work.74-    const protoDesc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!;75-    let instanceSetterCalled = false;76-    Object.defineProperty(el, "value", {77-      configurable: true,78-      get() { return protoDesc.get!.call(el); },79-      set() { instanceSetterCalled = true; /* React swallows direct assignment */ },80-    });81-82-    setNativeValue(el, "from-peach");83-84-    expect(instanceSetterCalled).toBe(false); // went around the instance setter85-    expect(el.value).toBe("from-peach");       // prototype setter wrote the slot86-    expect(trackerSawValue).toBe("from-peach"); // event fired AFTER the value was set87-  });88-89-  // T-FL05: Vue (and any framework) relies on the same input/change emission.90-  it("T-FL05: setNativeValue emits input then change for Vue-style v-model", () => {91-    const el = makeInput({ type: "text" });92-    const seq: string[] = [];93-    el.addEventListener("input", () => seq.push("input"));94-    el.addEventListener("change", () => seq.push("change"));95-    setNativeValue(el, "x");96-    expect(seq).toEqual(["input", "change"]);97-  });98-});99-100-describe("T-FL06: cross-origin / wrong-domain guard", () => {101-  // T-FL06: a true cross-origin iframe can't be scripted from jsdom, but the102-  // same defensive path fires when a form's action points at a different103-  // registrable domain (phished/relayed form). fillFields must refuse to fill104-  // and must not throw.105-  it("T-FL06: form action on a foreign domain → fields are not filled, no crash", () => {106-    const formEl = document.createElement("form");107-    formEl.action = "https://evil.example.org/collect";108-    document.body.appendChild(formEl);109-    const u = makeInput({ type: "text" }, formEl);110-    const p = makeInput({ type: "password" }, formEl);111-    expect(() =>112-      fillFields(u, p, "alice", "s3cret", formEl, "mybank.example.com")113-    ).not.toThrow();114-    expect(u.value).toBe("");115-    expect(p.value).toBe("");116-  });117-});118-119-describe("T-FL07–T-FL09: card fill + validation", () => {120-  // T-FL07: every card field that exists gets its value.121-  it("T-FL07: fills number, expiry combo, cvc, and cardholder name", () => {122-    const number = makeInput({ autocomplete: "cc-number" });123-    const expiryCombo = makeInput({ autocomplete: "cc-exp" });124-    const cvc = makeInput({ autocomplete: "cc-csc" });125-    const name = makeInput({ autocomplete: "cc-name" });126-    fillCardForm(127-      { number, expMonth: null, expYear: null, expiryCombo, cvc, name },128-      { cardNumber: "4111111111111111", expMonth: "08", expYear: "2027", cvc: "123", cardholderName: "Alice Wong" }129-    );130-    expect(number.value).toBe("4111 1111 1111 1111"); // formatted in 4-digit groups131-    expect(expiryCombo.value).toBe("08/27");132-    expect(cvc.value).toBe("123");133-    expect(name.value).toBe("Alice Wong");134-  });135-136-  // T-FL08: Luhn check — guards against a fill writing a transposed/typo'd PAN.137-  it("T-FL08: passesLuhn accepts valid PANs and rejects invalid ones", () => {138-    expect(passesLuhn("4111111111111111")).toBe(true); // canonical Visa test number139-    expect(passesLuhn("4111 1111 1111 1111")).toBe(true); // whitespace tolerated140-    expect(passesLuhn("4111111111111112")).toBe(false); // last digit wrong141-  });142-143-  // T-FL09: network detection from the leading digits drives the card icon.144-  it("T-FL09: detectCardNetwork keys off the issuer prefix", () => {145-    expect(detectCardNetwork("4111111111111111")).toBe("visa");146-    expect(detectCardNetwork("5500005555555559")).toBe("mastercard");147-    expect(detectCardNetwork("340000000000009")).toBe("amex");148-    expect(detectCardNetwork("6011000000000004")).toBe("discover");149-    expect(detectCardNetwork("9999999999999999")).toBe("unknown");150-  });151-152-  it("T-FL09b: amex (15-digit) numbers format in 4-6-5 groups", () => {153-    expect(formatCardNumber("340000000000009")).toBe("3400 000000 00009");154-    expect(extractLast4("3400 000000 00009")).toBe("0009");155-  });156-});157-158-describe("T-FL10–T-FL12: TOTP fill, preservation, and the return contract", () => {159-  // T-FL10: TOTP code goes into an empty one-time-code field.160-  it("T-FL10: fillTOTPCode inserts the code into an empty visible field", () => {161-    const totp = makeInput({ type: "text", autocomplete: "one-time-code" });162-    expect(fillTOTPCode(totp, "123456")).toBe(true);163-    expect(totp.value).toBe("123456");164-  });165-166-  it("T-FL10b: fillTOTPCode refuses to overwrite a non-empty field", () => {167-    const totp = makeInput({ type: "text", autocomplete: "one-time-code" });168-    setNativeValue(totp, "000000");169-    expect(fillTOTPCode(totp, "123456")).toBe(false);170-    expect(totp.value).toBe("000000");171-  });172-173-  // T-FL11: only the targeted fields are mutated — a stray "remember me" or174-  // search box on the same page keeps its value.175-  it("T-FL11: fields not passed to fillFields are left untouched", () => {176-    const u = makeInput({ type: "text" });177-    const p = makeInput({ type: "password" });178-    const other = makeInput({ type: "text" });179-    setNativeValue(other, "do-not-touch");180-    fillFields(u, p, "alice", "s3cret");181-    expect(other.value).toBe("do-not-touch");182-  });183-184-  // T-FL12: the brief assumes fillFields returns the filled set; the real API185-  // returns void and the caller (index.ts recordFillSuccess) tracks success by186-  // the elements it passed. We pin the actual contract: fillFields returns187-  // undefined, and an invisible password is intentionally skipped — which is how188-  // a caller can tell a fill was partial. (See INTEGRATION-CONTRACT.md.)189-  it("T-FL12: fillFields returns void and skips an invisible password field", () => {190-    const u = makeInput({ type: "text" });191-    const p = makeInput({ type: "password" });192-    p.style.display = "none"; // invisible → isVisible() false → not filled193-    const result = fillFields(u, p, "alice", "s3cret");194-    expect(result).toBeUndefined();195-    expect(u.value).toBe("alice");196-    expect(p.value).toBe(""); // skipped because not visible197-  });198-});

view file at the parent

src/drafts/AUTOFILL-TESTS/content.initial-scan.test.ts+0 −144
@@ -1,144 +0,0 @@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("../../content/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/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 "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.interaction.test.ts+0 −115
@@ -1,115 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 12 — interaction.ts target resolution (T-IT01–T-IT06).4-//5-// resolveAutofillInteractionTargets is the small, pure decision that turns a set6-// of detected fields + the clicked anchor into "what do we fill, and do we even7-// open?". It is deliberately narrow: it does NOT scope by form (the caller hands8-// it a pre-scoped field list) and it does NOT inspect the live DOM. The tests9-// below pin its real contract; where the brief assumed broader behavior10-// (form-scoping, email-first shape), the deviation is noted inline and in11-// INTEGRATION-CONTRACT.md.12-13-import { beforeEach, describe, expect, it } from "vitest";14-15-import type { DetectedField } from "../../content/detect";16-import { resolveAutofillInteractionTargets } from "../../content/interaction";17-import { makeInput } from "./_setup";18-19-beforeEach(() => {20-  document.body.innerHTML = "";21-});22-23-function field(type: DetectedField["type"], el: HTMLInputElement): DetectedField {24-  return { type, el };25-}26-27-describe("resolveAutofillInteractionTargets", () => {28-  // T-IT01: a standard form resolves both the username and password elements and29-  // opens.30-  it("T-IT01: standard form → {usernameEl, passwordEl} and shouldOpen", () => {31-    const u = makeInput({ type: "text" });32-    const p = makeInput({ type: "password" });33-    const targets = resolveAutofillInteractionTargets(p, [field("username", u), field("password", p)]);34-    expect(targets.usernameEl).toBe(u);35-    expect(targets.passwordEl).toBe(p);36-    expect(targets.emailEl).toBeNull();37-    expect(targets.shouldOpen).toBe(true);38-  });39-40-  // T-IT02: email-first step has no password. The function treats a lone email41-  // as the identity field (usernameEl), so the caller fills entry.username into42-  // it; emailEl stays null because there is no *separate* username field. (The43-  // brief's {emailEl, passwordEl:null} shape predates this consolidation.)44-  it("T-IT02: email-first form → email becomes usernameEl, passwordEl null", () => {45-    const e = makeInput({ type: "email" });46-    const targets = resolveAutofillInteractionTargets(e, [field("email", e)]);47-    expect(targets.usernameEl).toBe(e);48-    expect(targets.passwordEl).toBeNull();49-    expect(targets.emailEl).toBeNull();50-    // shouldOpen depends on isAnyCredentialForm; an email field with no auth51-    // context on a "/" path is not yet a credential form, so passive open is52-    // gated. An explicit summon overrides (next test).53-  });54-55-  // T-IT02b: distinct username AND email → both tracked separately.56-  it("T-IT02b: distinct username + email → emailEl is tracked alongside usernameEl", () => {57-    const u = makeInput({ type: "text", autocomplete: "username" });58-    const e = makeInput({ type: "email", autocomplete: "email" });59-    const p = makeInput({ type: "password" });60-    const targets = resolveAutofillInteractionTargets(61-      u,62-      [field("username", u), field("email", e), field("password", p)]63-    );64-    expect(targets.usernameEl).toBe(u);65-    expect(targets.emailEl).toBe(e);66-    expect(targets.passwordEl).toBe(p);67-  });68-69-  // T-IT03: password-only step → passwordEl set, usernameEl null, and it opens70-  // (isPasswordStepForm).71-  it("T-IT03: password-only form → passwordEl set, usernameEl null, shouldOpen", () => {72-    const p = makeInput({ type: "password", autocomplete: "current-password" });73-    const targets = resolveAutofillInteractionTargets(p, [field("password", p)]);74-    expect(targets.passwordEl).toBe(p);75-    expect(targets.usernameEl).toBeNull();76-    expect(targets.shouldOpen).toBe(true);77-  });78-79-  // T-IT04: no qualifying fields → do not open (passive).80-  it("T-IT04: empty field set + passive → shouldOpen false", () => {81-    const anchor = makeInput({ type: "text" });82-    const targets = resolveAutofillInteractionTargets(anchor, []);83-    expect(targets.shouldOpen).toBe(false);84-    expect(targets.usernameEl).toBeNull();85-    expect(targets.passwordEl).toBeNull();86-  });87-88-  // T-IT05: the function resolves targets from the field list it is GIVEN — form89-  // scoping is the caller's job (index.ts calls detectLoginFields per the focused90-  // field's context). With explicitSummon, a bare anchor still resolves as a91-  // fallback target so an icon click always opens something.92-  it("T-IT05: explicit summon on a bare password anchor falls back to the anchor", () => {93-    const anchor = makeInput({ type: "password" });94-    const targets = resolveAutofillInteractionTargets(anchor, [], true);95-    expect(targets.shouldOpen).toBe(true);96-    expect(targets.passwordEl).toBe(anchor);97-  });98-99-  it("T-IT05b: explicit summon on a bare username anchor falls back to the anchor", () => {100-    const anchor = makeInput({ type: "text" });101-    const targets = resolveAutofillInteractionTargets(anchor, [], true);102-    expect(targets.shouldOpen).toBe(true);103-    expect(targets.usernameEl).toBe(anchor);104-  });105-106-  // T-IT06: a detached anchor with no detected fields resolves to a non-opening107-  // result rather than throwing — the graceful-abort path index.ts relies on.108-  it("T-IT06: detached anchor + empty fields → shouldOpen false, no throw", () => {109-    const detached = document.createElement("input");110-    detached.type = "text"; // never appended to the document111-    let targets!: ReturnType<typeof resolveAutofillInteractionTargets>;112-    expect(() => { targets = resolveAutofillInteractionTargets(detached, []); }).not.toThrow();113-    expect(targets.shouldOpen).toBe(false);114-  });115-});

view file at the parent

src/drafts/AUTOFILL-TESTS/content.message-routing.test.ts+0 −184
@@ -1,184 +0,0 @@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("../../content/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/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 "../../content/index"; // registers the onMessage listener at import time41-import * as inject from "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.policy.test.ts+0 −126
@@ -1,126 +0,0 @@1-// @vitest-environment jsdom2-//3-// Test File 11 — policy.ts site password policy (T-PO01–T-PO06).4-//5-// detectPasswordPolicy scrapes the page so Peach's generator can produce a6-// password the site will actually accept. Its real inputs are: the password7-// field's maxLength attribute, its pattern attribute, and nearby descriptive8-// text (aria-describedby, hints, labels). It does NOT read a minlength attribute9-// and it inspects only the first password field — where the brief assumed10-// otherwise (T-PO01 minlength, T-PO05 generator wiring, T-PO06 multi-field11-// "most restrictive"), the deviation is pinned and documented in12-// INTEGRATION-CONTRACT.md so the gap is explicit, not silently "passing".13-14-import { beforeEach, describe, expect, it } from "vitest";15-16-import { detectPasswordPolicy, extractNearbyText } from "../../content/policy";17-18-beforeEach(() => {19-  document.body.innerHTML = "";20-});21-22-function pwField(html: string): void {23-  document.body.innerHTML = html;24-}25-26-describe("T-PO01–T-PO04: attribute / pattern / text extraction", () => {27-  // T-PO01: minLength is NOT read from a minlength attribute — only from the28-  // pattern or descriptive text. This test pins the real source (pattern) and29-  // documents that a bare minlength attribute yields nothing.30-  it("T-PO01: minLength comes from the pattern, not the minlength attribute", () => {31-    pwField('<input type="password" minlength="12" />');32-    expect(detectPasswordPolicy().minLength).toBeUndefined();33-34-    pwField('<input type="password" pattern=".{8,}" />');35-    expect(detectPasswordPolicy().minLength).toBe(8);36-  });37-38-  // T-PO02: maxLength IS read directly from the attribute.39-  it("T-PO02: maxlength attribute → policy.maxLength", () => {40-    pwField('<input type="password" maxlength="64" />');41-    expect(detectPasswordPolicy().maxLength).toBe(64);42-  });43-44-  // T-PO03: a range pattern yields both bounds; forbidden characters are derived45-  // from negation TEXT (not the pattern). Pin both real mechanisms.46-  it("T-PO03: range pattern → min+max; negation text → forbidden chars", () => {47-    pwField('<input type="password" pattern=".{10,32}" />');48-    const p1 = detectPasswordPolicy();49-    expect(p1.minLength).toBe(10);50-    expect(p1.maxLength).toBe(32);51-52-    pwField(53-      '<div><input type="password" id="pw" aria-describedby="rule" />' +54-        '<p id="rule">Password cannot contain ` ~ < > characters.</p></div>'55-    );56-    const p2 = detectPasswordPolicy();57-    expect(p2.forbiddenChars).toBeTruthy();58-    for (const ch of ["`", "~", "<", ">"]) expect(p2.forbiddenChars).toContain(ch);59-  });60-61-  // T-PO04: no policy signals anywhere → an empty policy object (the generator62-  // then uses its own defaults).63-  it("T-PO04: no attributes or hints → empty policy", () => {64-    pwField('<input type="password" />');65-    expect(detectPasswordPolicy()).toEqual({});66-  });67-});68-69-describe("T-PO01b: human-readable length ranges in nearby text", () => {70-  // The most common real-world phrasing is "8-20 characters" in a hint element.71-  it("dash range '8-20 characters' → min 8, max 20", () => {72-    pwField(73-      '<div><input type="password" id="pw" aria-describedby="h" />' +74-        '<span id="h">Use 8-20 characters.</span></div>'75-    );76-    const p = detectPasswordPolicy();77-    expect(p.minLength).toBe(8);78-    expect(p.maxLength).toBe(20);79-  });80-81-  it("'at least 12 characters' → min 12", () => {82-    pwField(83-      '<div><input type="password" id="pw" aria-describedby="h" />' +84-        '<small id="h">Must be at least 12 characters.</small></div>'85-    );86-    expect(detectPasswordPolicy().minLength).toBe(12);87-  });88-});89-90-describe("T-PO05–T-PO06: documented scope boundaries", () => {91-  // T-PO05 (generator integration) lives in the generator's own suite, not here:92-  // detectPasswordPolicy only PRODUCES the policy. We pin the shape the generator93-  // consumes so a field rename would break a real contract.94-  it("T-PO05: produced policy uses the PasswordPolicy field names the generator reads", () => {95-    pwField('<input type="password" pattern=".{6,18}" maxlength="18" />');96-    const p = detectPasswordPolicy();97-    expect(Object.keys(p).sort()).toEqual(["maxLength", "minLength"]);98-  });99-100-  // T-PO06: only the FIRST new-password/password field is inspected. "Most101-  // restrictive across multiple fields" is not implemented — pin the actual102-  // single-field behavior (new-password is preferred by selector order).103-  it("T-PO06: reads the first matching password field (new-password preferred)", () => {104-    pwField(105-      '<input type="password" autocomplete="new-password" pattern=".{16,}" />' +106-        '<input type="password" autocomplete="current-password" />'107-    );108-    expect(detectPasswordPolicy().minLength).toBe(16);109-  });110-});111-112-describe("extractNearbyText", () => {113-  // extractNearbyText is the scraping primitive; null input must be safe.114-  it("returns empty string for a null field", () => {115-    expect(extractNearbyText(null)).toBe("");116-  });117-118-  it("pulls aria-describedby, title, placeholder, and aria-label together", () => {119-    document.body.innerHTML =120-      '<input type="password" id="pw" title="t-text" placeholder="p-text" ' +121-      'aria-label="a-text" aria-describedby="d" /><span id="d">d-text</span>';122-    const el = document.querySelector<HTMLInputElement>("#pw")!;123-    const text = extractNearbyText(el);124-    for (const s of ["t-text", "p-text", "a-text", "d-text"]) expect(text).toContain(s);125-  });126-});

view file at the parent

src/drafts/AUTOFILL-TESTS/content.real-page.e2e.spec.ts+0 −191
@@ -1,191 +0,0 @@1-// DRAFT — Playwright e2e (T-028). Moves to tests/content.real-page.e2e.spec.ts2-// during integration; the fixture login-form.fixture.html moves to3-// tests/fixtures/login-form.html. Named *.spec.ts (Playwright's testMatch) and4-// kept in src/drafts/ so neither the vitest suite nor the drafts runner picks it5-// up. It is NOT run as part of this draft pass — it needs the built extension in6-// dist/ and a served origin.7-//8-// WHY THIS LAYER EXISTS: the inline selector mounts a CLOSED shadow root, so its9-// interior (entry list, arrow/Enter/Escape nav, search filter, TOTP chip) cannot10-// be asserted by reading the DOM from any unit test. The e2e verifies these11-// through EFFECTS instead — the light-DOM icon button appears and is clickable,12-// fields actually receive values, the host appears/disappears, and the13-// dismissal/grace behavior is observed end to end. This is the home for the14-// it.todo cases left in content.selector-ui.test.ts (T-I02–T-I16) and the15-// gesture-dependent dismissal cases (T-D14/T-D15) and save acceptance flows16-// (T-SV06–T-SV09).17-//18-// SETUP ASSUMPTIONS for the integrator:19-//   - Serve the fixture from a real origin so domain matching works. Either a20-//     tiny static server (http://localhost:<port>/login-form.html) or21-//     page.route() interception mapping an https origin to the fixture file.22-//     file:// will NOT exercise domain-scoped vault matching correctly.23-//   - Seed the vault with one entry whose url matches that origin and whose24-//     username/password are known, via the popup or an IMPORT message.25-26-import { test, expect } from "@playwright/test";27-import type { BrowserContext, Page } from "@playwright/test";28-import { fileURLToPath } from "url";29-import { dirname, join } from "path";30-import os from "os";31-import fs from "fs/promises";32-33-const __filename = fileURLToPath(import.meta.url);34-const __dirname = dirname(__filename);35-const EXTENSION_PATH = join(__dirname, "..", "..", "..", "dist");36-// After the move to tests/, this becomes join(__dirname, "fixtures", "login-form.html").37-const FIXTURE = join(__dirname, "login-form.fixture.html");38-const ICON = 'button[aria-label="Peach autofill"]';39-40-async function makeContext(): Promise<BrowserContext> {41-  const { chromium } = await import("playwright");42-  const userDataDir = join(os.tmpdir(), `peach-e2e-${Date.now()}`);43-  await fs.mkdir(userDataDir, { recursive: true });44-  return chromium.launchPersistentContext(userDataDir, {45-    headless: false,46-    args: [`--load-extension=${EXTENSION_PATH}`, `--disable-extensions-except=${EXTENSION_PATH}`],47-  });48-}49-50-// Unlock/create the vault and seed one entry for the fixture origin. Left as a51-// helper skeleton — wire to the project's popup setup (see autofill.spec.ts52-// setupVault) and an IMPORT/ADD_ENTRY for the seeded credential.53-async function setupVaultWithEntry(_ctx: BrowserContext, _origin: string): Promise<void> {54-  // TODO(integration): reuse setupVault() from autofill.spec.ts, then add an55-  // entry { url: origin, username: "alice", password: "s3cret" }.56-}57-58-// Serve the fixture from a real origin. TODO(integration): replace with a static59-// server or page.route mapping; returning the file path is a placeholder so the60-// spec reads end-to-end.61-async function openFixture(ctx: BrowserContext): Promise<{ page: Page; origin: string }> {62-  const page = await ctx.newPage();63-  await page.goto(`file://${FIXTURE}`); // placeholder — see SETUP ASSUMPTIONS64-  return { page, origin: new URL(page.url()).origin };65-}66-67-test.describe("Real-page autofill e2e (T-E01–T-E10)", () => {68-  let ctx: BrowserContext;69-70-  test.beforeAll(async () => {71-    ctx = await makeContext();72-  });73-  test.afterAll(async () => {74-    await ctx?.close();75-  });76-77-  // T-E01: the field icon appears in the password field of the standard form.78-  test("T-E01: field icon appears in the password field", async () => {79-    const { page } = await openFixture(ctx);80-    await expect(page.locator(ICON)).toBeVisible();81-    await page.close();82-  });83-84-  // T-E02: clicking the icon opens the selector (host mounts; closed shadow, so85-  // assert on the host's presence and the absence of a page-level error).86-  test("T-E02: clicking the icon opens the selector for domain-matched entries", async () => {87-    const { page, origin } = await openFixture(ctx);88-    await setupVaultWithEntry(ctx, origin);89-    await page.locator(ICON).click();90-    await expect(page.locator('div[style*="2147483647"]')).toBeVisible();91-    await page.close();92-  });93-94-  // T-E03: selecting the entry fills username and password correctly (the effect95-  // that proves the closed-shadow selection worked).96-  test("T-E03: selecting an entry fills username and password", async () => {97-    const { page, origin } = await openFixture(ctx);98-    await setupVaultWithEntry(ctx, origin);99-    await page.locator(ICON).click();100-    // TODO(integration): activate the first entry. The row is in a closed shadow,101-    // so drive it via keyboard on the focused host (ArrowDown + Enter) and assert102-    // the fill effect rather than locating the row.103-    await page.keyboard.press("Enter");104-    await expect(page.locator("#username")).toHaveValue("alice");105-    await expect(page.locator("#password")).toHaveValue("s3cret");106-    await page.close();107-  });108-109-  // T-E04: after fill, the icon remains but the selector host is gone.110-  test("T-E04: after fill the selector closes, the icon remains", async () => {111-    const { page, origin } = await openFixture(ctx);112-    await setupVaultWithEntry(ctx, origin);113-    await page.locator(ICON).click();114-    await page.keyboard.press("Enter");115-    await expect(page.locator('div[style*="2147483647"]')).toHaveCount(0);116-    await expect(page.locator(ICON)).toBeVisible();117-    await page.close();118-  });119-120-  // T-E05: clicking the same field immediately after fill does NOT reopen121-  // (fill-success grace — the Layer 3 behavior, observed end to end).122-  test("T-E05: clicking the filled field within the grace window does not reopen", async () => {123-    const { page, origin } = await openFixture(ctx);124-    await setupVaultWithEntry(ctx, origin);125-    await page.locator(ICON).click();126-    await page.keyboard.press("Enter");127-    await page.locator("#password").click();128-    await expect(page.locator('div[style*="2147483647"]')).toHaveCount(0);129-    await page.close();130-  });131-132-  // T-E06: after the grace window (>3s), clicking the field opens again.133-  test("T-E06: after the grace window the selector opens again", async () => {134-    const { page, origin } = await openFixture(ctx);135-    await setupVaultWithEntry(ctx, origin);136-    await page.locator(ICON).click();137-    await page.keyboard.press("Enter");138-    await page.waitForTimeout(3200); // past PAGE_FILL_GRACE_MS139-    await page.locator("#password").click();140-    await expect(page.locator('div[style*="2147483647"]')).toBeVisible();141-    await page.close();142-  });143-144-  // T-E07: submitting the form after a fill keeps post-submit suppression active145-  // (no save banner for the just-filled, unchanged credential).146-  test("T-E07: form submit after fill does not re-prompt to save unchanged creds", async () => {147-    const { page, origin } = await openFixture(ctx);148-    await setupVaultWithEntry(ctx, origin);149-    await page.locator(ICON).click();150-    await page.keyboard.press("Enter");151-    await page.evaluate(() => (document.getElementById("login-form") as HTMLFormElement).requestSubmit());152-    // No save/update banner for an unchanged saved credential.153-    await page.waitForTimeout(500);154-    await expect(page.locator('div[style*="2147483647"]')).toHaveCount(0);155-    await page.close();156-  });157-158-  // T-E08: the email-first form (shown) gets an icon on the email field and159-  // opens an email surface.160-  test("T-E08: email-first form gets an email icon and opens", async () => {161-    const { page } = await openFixture(ctx);162-    await page.evaluate(() => ((document.getElementById("email-first-form") as HTMLElement).style.display = "block"));163-    await expect(page.locator(ICON)).toHaveCount(1); // login form's icon; email form adds another after rescan164-    await page.close();165-  });166-167-  // T-E09: revealing the signup form → signup detection (identity surface).168-  test("T-E09: signup form detection shows the identity surface", async () => {169-    const { page } = await openFixture(ctx);170-    await page.evaluate(() => ((document.getElementById("signup-form") as HTMLElement).style.display = "block"));171-    await page.locator("#new-password").click();172-    await expect(page.locator('div[style*="2147483647"]')).toBeVisible();173-    await page.close();174-  });175-176-  // T-E10: a cross-origin iframe on the page must not crash the content script;177-  // the main form still works.178-  test("T-E10: cross-origin iframe present → no crash, main form still fills", async () => {179-    const { page, origin } = await openFixture(ctx);180-    await setupVaultWithEntry(ctx, origin);181-    await page.evaluate(() => {182-      const f = document.createElement("iframe");183-      f.src = "https://example.com/";184-      document.body.appendChild(f);185-    });186-    await page.locator(ICON).first().click();187-    await page.keyboard.press("Enter");188-    await expect(page.locator("#password")).toHaveValue("s3cret");189-    await page.close();190-  });191-});

view file at the parent

src/drafts/AUTOFILL-TESTS/content.save.test.ts+0 −136
@@ -1,136 +0,0 @@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 "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.selector-ui.test.ts+0 −107
@@ -1,107 +0,0 @@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 "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/content.totp-toast.addendum.test.ts+0 −59
@@ -1,59 +0,0 @@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 "../../content/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 at the parent

src/drafts/AUTOFILL-TESTS/login-form.fixture.html+0 −32
@@ -1,32 +0,0 @@1-<!DOCTYPE html>2-<!--3-  DRAFT FIXTURE — moves to tests/fixtures/login-form.html during integration.4-  Referenced by content.real-page.e2e.spec.ts (T-E01–T-E10).5--->6-<html>7-<head><title>Login Fixture</title></head>8-<body>9-  <!-- Standard login form -->10-  <form id="login-form" action="/submit" method="post">11-    <label for="username">Username</label>12-    <input type="text" id="username" name="username" autocomplete="username" />13-    <label for="password">Password</label>14-    <input type="password" id="password" name="password" autocomplete="current-password" />15-    <button type="submit" id="submit-btn">Log in</button>16-  </form>17-18-  <!-- Email-first form (separate) -->19-  <form id="email-first-form" style="display:none">20-    <input type="email" id="email" name="email" autocomplete="email" />21-    <button type="submit">Next</button>22-  </form>23-24-  <!-- Signup form (separate) -->25-  <form id="signup-form" style="display:none">26-    <input type="text" id="new-username" name="username" />27-    <input type="password" id="new-password" name="password" autocomplete="new-password" />28-    <input type="password" id="confirm-password" name="confirm" autocomplete="new-password" />29-    <button type="submit">Create account</button>30-  </form>31-</body>32-</html>

view file at the parent