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
↓ peach-browser-6f1bb7a6.tar.gzatlas at this save
files changed5 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 −0src/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+});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+});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+});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).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+});