6f1bb7a67d67

add index.ts orchestration AUTOFILL-TESTS drafts: dismissal 3-layer model (T-D01-15 via _testSeam + captured onDismiss/onFillFields, fake timers for DISMISS_LOCK/grace), handleFieldInteraction core paths (T-F01-12 incl ML 150ms timeout race), initialScan aggression levels (T-S01-10), message routing (T-M01-10) + TOTP-toast addendum skipIf spec — mocks ./inject + ./content-ml-detector, drives real seams

time
2026-06-12 12:51 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
autofill-tests draft suite
parent
17740443b130
5 files src/drafts/AUTOFILL-TESTS/content.dismissal.test.ts+268 −0src/drafts/AUTOFILL-TESTS/content.field-interaction.test.ts+194 −0src/drafts/AUTOFILL-TESTS/content.initial-scan.test.ts+144 −0src/drafts/AUTOFILL-TESTS/content.message-routing.test.ts+184 −0src/drafts/AUTOFILL-TESTS/content.totp-toast.addendum.test.ts+59 −0
src/drafts/AUTOFILL-TESTS/content.dismissal.test.ts+268 −0
@@ -0,0 +1,268 @@1+// @vitest-environment jsdom2+//3+// Test File 1 — index.ts 3-layer dismissal model (T-D01–T-D15).4+//5+// This is the most regression-prone logic in the project (v1.4.14 broke it6+// repeatedly). Three independent layers each prevent the selector reopening:7+//   Layer 1 — lastDismissedField: per-field lock until focus genuinely leaves.8+//   Layer 2 — dismissLock (DISMISS_LOCK_MS=800): global post-dismiss cooldown.9+//   Layer 3 — wasAlreadyFilled: filledForms (form-level, permanent) + pageFilledAt10+//             (page-level, PAGE_FILL_GRACE_MS=3000 window).11+//12+// All three short-circuit handleFieldInteraction BEFORE the ML race, so we drive13+// them through the exported seams: _testSeam (read/write lastDismissedField,14+// read dismissLock), the captured onDismiss callback (to fire a real dismiss →15+// startDismissLock), and the captured onFillFields callback (to fire a real fill16+// → recordFillSuccess). Fake timers are used ONLY for the expiry windows, per17+// the brief's quality bar.18+19+import { vi } from "vitest";20+21+const { predictBatch, dismissSpy } = vi.hoisted(() => ({22+  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),23+  dismissSpy: vi.fn(),24+}));25+26+vi.mock("../../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

src/drafts/AUTOFILL-TESTS/content.field-interaction.test.ts+194 −0
@@ -0,0 +1,194 @@1+// @vitest-environment jsdom2+//3+// Test File 2 — index.ts handleFieldInteraction core paths (T-F01–T-F12).4+//5+// handleFieldInteraction is the central decision: ML predict → suppression6+// layers → TOTP/OTP branch → vault query → which surface opens. We mock the7+// boundary it talks to — ./inject (so we can see WHICH surface opened) and8+// ./content-ml-detector (so we can control predict timing) — and drive the REAL9+// exported function with controlled DOM + chrome responses. Nothing about the10+// decision logic itself is mocked.11+12+import { vi } from "vitest";13+14+// Spies shared with the hoisted vi.mock factories must themselves be hoisted.15+const { predictBatch, dismissSpy } = vi.hoisted(() => ({16+  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),17+  dismissSpy: vi.fn(),18+}));19+20+// Observe which inject surface opens. showAutofillSelector returns a dismiss fn.21+vi.mock("../../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

src/drafts/AUTOFILL-TESTS/content.initial-scan.test.ts+144 −0
@@ -0,0 +1,144 @@1+// @vitest-environment jsdom2+//3+// Test File 3 — index.ts initialScan aggression levels (T-S01–T-S10).4+//5+// initialScan decides where the field icon is injected at page load. Aggression6+// climbs with each pass: level 0 only touches pages with a recognised credential7+// form; level 2 final-pass injects on a bare password field even without8+// surrounding form context (SPA reveal-after-username case). We mock ./inject so9+// injectFieldIcon is observable, and assert on its call count — the real10+// regression surface is "icon missing" or "icon on the wrong field".11+12+import { vi } from "vitest";13+14+const { injectIcon } = vi.hoisted(() => ({ injectIcon: vi.fn(() => vi.fn()) }));15+16+vi.mock("../../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

src/drafts/AUTOFILL-TESTS/content.message-routing.test.ts+184 −0
@@ -0,0 +1,184 @@1+// @vitest-environment jsdom2+//3+// Test File 4 — index.ts message routing (T-M01–T-M13).4+//5+// index.ts registers a single chrome.runtime.onMessage listener that routes6+// SUMMON_AUTOFILL (keyboard shortcut / toolbar button), VAULT_UNLOCKED (retry7+// after unlock), PING, GET_SITE_PASSWORD_POLICY, and the passkey-toast control8+// messages. The harness captures every registered listener; we invoke them with9+// a spy sendResponse and a same-extension sender, then assert the routed effect.10+//11+// Where the brief assumed messages that this listener does not own12+// (DISMISS_AUTOFILL, FILL_AND_SUBMIT), and the Addendum-2 showTotpCopyToast that13+// does not exist yet, the cases are documented rather than faked. See14+// INTEGRATION-CONTRACT.md.15+16+import { vi } from "vitest";17+18+const { predictBatch, dismissSpy } = vi.hoisted(() => ({19+  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),20+  dismissSpy: vi.fn(),21+}));22+23+vi.mock("../../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

src/drafts/AUTOFILL-TESTS/content.totp-toast.addendum.test.ts+59 −0
@@ -0,0 +1,59 @@1+// @vitest-environment jsdom2+//3+// Addendum 2 — TOTP auto-copy toast (T-M11–T-M13), proposed feature.4+//5+// Today, when Peach auto-copies a TOTP code it shows the generic "2FA code6+// copied" toast (showToast in index.ts). The addendum asks for a dedicated7+// showTotpCopyToast(secondsUntilClear) that renders a passkey-family toast8+// ("2FA code copied / clears in 30s") and auto-dismisses after 3s, resetting its9+// timer if a second copy lands while it is visible.10+//11+// showTotpCopyToast does not exist yet. This file imports the REAL inject.ts and12+// activates the spec the moment the export appears — so it is an executable13+// contract, not a stub that always passes. See INTEGRATION-CONTRACT.md.14+15+import "./_setup"; // installs the chrome mock inject.ts touches at import time16+import { describe, expect, it, vi } from "vitest";17+import * as inject from "../../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