// @vitest-environment jsdom // // Test File 2 — index.ts handleFieldInteraction core paths (T-F01–T-F12). // // handleFieldInteraction is the central decision: ML predict → suppression // layers → TOTP/OTP branch → vault query → which surface opens. We mock the // boundary it talks to — ./inject (so we can see WHICH surface opened) and // ./content-ml-detector (so we can control predict timing) — and drive the REAL // exported function with controlled DOM + chrome responses. Nothing about the // decision logic itself is mocked. import { vi } from "vitest"; // Spies shared with the hoisted vi.mock factories must themselves be hoisted. const { predictBatch, dismissSpy } = vi.hoisted(() => ({ predictBatch: vi.fn(() => Promise.resolve([] as unknown[])), dismissSpy: vi.fn(), })); // Observe which inject surface opens. showAutofillSelector returns a dismiss fn. vi.mock("./inject", () => ({ injectFieldIcon: vi.fn(() => vi.fn()), showAutofillSelector: vi.fn(() => dismissSpy), showTOTPSelector: vi.fn(() => dismissSpy), showCardSelector: vi.fn(() => dismissSpy), showRememberSiteToast: vi.fn(), showPasskeyAvailable: vi.fn(), showPasskeyProGate: vi.fn(), showPasskeyRegistrationOffer: vi.fn(), })); // Control ML predict timing/result per test. vi.mock("./content-ml-detector", () => ({ contentMLDetector: { predictBatch }, })); import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./_setup"; import { setMessageResponder, resetChromeMock, sentMessages, makeInput, createLoginForm, createEmailFirstForm } from "./_setup"; import { handleFieldInteraction } from "./index"; import { showAutofillSelector, showTOTPSelector } from "./inject"; const showSelector = showAutofillSelector as unknown as ReturnType; const showTOTP = showTOTPSelector as unknown as ReturnType; // Reset module-private suppression state between tests by exercising the public // surface: a fresh DOM + cleared mocks. handleFieldInteraction reads live DOM // each call, so a clean body is a clean slate for everything except the global // dismiss lock — which we never trip in this file (no dismissals here). beforeEach(() => { document.body.innerHTML = ""; resetChromeMock(); showSelector.mockClear(); showTOTP.mockClear(); predictBatch.mockReset(); predictBatch.mockReturnValue(Promise.resolve([])); }); afterEach(async () => { vi.useRealTimers(); // index.ts installs a url-watcher MutationObserver on document.body that // reads location.href. Clearing the DOM each test queues its callback; flush // it here so it runs while jsdom's globals still exist, instead of throwing // "location is not defined" during environment teardown. await new Promise((r) => setTimeout(r, 0)); }); // Last options object passed to showAutofillSelector (4th arg). function lastSelectorOptions(): any { const calls = showSelector.mock.calls; return calls.length ? calls[calls.length - 1]![3] : undefined; } describe("T-F01–T-F02: ML predict timeout race (150ms)", () => { // T-F02: when ML resolves IN TIME with a non-credential verdict for a text // field, handleFieldInteraction suppresses — the selector must NOT open. it("T-F02: ML resolves fast with a suppressing prediction → no selector", async () => { const { username } = createLoginForm(); predictBatch.mockReturnValue(Promise.resolve([{ type: "search", confidence: 0.9 }])); await handleFieldInteraction(username); // passive (explicitSummon=false) // give any stray microtasks a tick await new Promise((r) => setTimeout(r, 20)); expect(showSelector).not.toHaveBeenCalled(); }); // T-F01: when ML is SLOWER than the 150ms cap, the timeout wins, the // prediction is discarded (treated as null), and the heuristic path proceeds — // so the very same suppressing verdict no longer blocks the selector. This is // the regression that froze autofill on pages with a slow ML worker. it("T-F01: ML slower than 150ms → timeout path taken, selector still opens", async () => { const { username } = createLoginForm(); predictBatch.mockReturnValue( new Promise((resolve) => setTimeout(() => resolve([{ type: "search", confidence: 0.9 }]), 250)) ); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null } ); await handleFieldInteraction(username); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled(), { timeout: 1000 }); }); }); describe("T-F03–T-F05: vault state + form shape", () => { // T-F03: a locked vault shows the inline unlock prompt (showAutofillSelector // with {locked:true}), never the entry list. it("T-F03: locked vault → selector opened in locked mode", async () => { const { password } = createLoginForm(); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: false, error: "vault_locked" } : { ok: true, data: null } ); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(lastSelectorOptions()?.locked).toBe(true); }); // T-F04: unlocked vault with no entries for this domain → signup/identity // surface (isSignup:true), not a credential list. it("T-F04: no domain entries → signup/identity surface", async () => { const { password } = createLoginForm(); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null } ); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(lastSelectorOptions()?.isSignup).toBe(true); expect(lastSelectorOptions()?.locked).toBeFalsy(); expect(sentMessages.some((msg) => msg.type === "GET_IDENTITIES")).toBe(false); }); // T-F05: an email-first form (no password on this step) still opens on the // email field — the path must not bail just because there is no password. it("T-F05: email-first form opens on the email field", async () => { const { email } = createEmailFirstForm(); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null } ); await handleFieldInteraction(email); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); }); }); describe("T-F07: TOTP field path", () => { // T-F07: a lone verification-code field (no login form present) routes to the // TOTP selector, not the credential selector. it("T-F07: standalone one-time-code field → TOTP selector", async () => { const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "t1", title: "Acme 2FA" }] } : { ok: true, data: null } ); await handleFieldInteraction(totp); await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled()); expect(showSelector).not.toHaveBeenCalled(); }); }); describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => { // T-F09: autocomplete="off" on the form must not stop Peach offering — the // user installed Peach precisely to override the site's opt-out. it("T-F09: form with autocomplete=off still offers", async () => { const form = document.createElement("form"); form.setAttribute("autocomplete", "off"); document.body.appendChild(form); makeInput({ type: "text", name: "username", autocomplete: "username" }, form); const password = makeInput({ type: "password", autocomplete: "current-password" }, form); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null } ); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); }); }); describe("T-F10–T-F12: robustness", () => { // T-F10: a field whose ownerDocument is a different (cross-origin-style) // context must be handled without crashing. We approximate by anchoring a // detached input — the function must abort cleanly, never throw. it("T-F10/T-F12: detached field mid-interaction → clean abort, no throw, no selector", async () => { const detached = document.createElement("input"); detached.type = "password"; // never attached → isVisible() false await expect(handleFieldInteraction(detached)).resolves.toBeUndefined(); expect(showSelector).not.toHaveBeenCalled(); }); // T-F11: two concurrent calls for the SAME field (focusin + icon click racing) // must not double-open — pendingInteractions debounces the second. it("T-F11: concurrent calls for the same field → selector opens at most once", async () => { const { password } = createLoginForm(); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null } ); await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(showSelector).toHaveBeenCalledTimes(1); }); }); describe("PB-26-201: browser-command capability propagation", () => { it("passes the capability through form inventory and the selected fill", async () => { const { password } = createLoginForm(); setMessageResponder((msg) => { if (msg.type === "AUTOFILL_QUERY") { return { ok: true, data: [{ id: "foreign-entry", title: "Foreign", username: "alice" }] }; } if (msg.type === "GET_ENTRY_FOR_FILL") { return { ok: true, data: { id: "foreign-entry", title: "Foreign", username: "alice", password: "secret" } }; } return { ok: true, data: null }; }); await handleFieldInteraction(password, true, "form-capability"); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({ payload: { includeAll: true, capability: "form-capability" }, }); const onSelect = showSelector.mock.calls.at(-1)![2] as (id: string) => Promise; await onSelect("foreign-entry"); expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({ payload: { id: "foreign-entry", capability: "form-capability" }, }); }); it("passes the capability through TOTP inventory and the selected fill", async () => { const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }); setMessageResponder((msg) => { if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "totp-entry", title: "Foreign 2FA" }] }; if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "totp-entry", totpCode: "123456" } }; return { ok: true, data: null }; }); await handleFieldInteraction(totp, true, "totp-capability"); await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled()); expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({ payload: { kind: "totp", capability: "totp-capability" }, }); const onSelect = showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise; await onSelect("totp-entry"); expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({ payload: { id: "totp-entry", capability: "totp-capability" }, }); }); });