123456789101112import { vi } from "vitest";131415const { predictBatch, dismissSpy } = vi.hoisted(() => ({16 predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),17 dismissSpy: vi.fn(),18}));192021vi.mock("./inject", () => ({22 injectFieldIcon: vi.fn(() => vi.fn()),23 showAutofillSelector: vi.fn(() => dismissSpy),24 showTOTPSelector: vi.fn(() => dismissSpy),25 showCardSelector: vi.fn(() => dismissSpy),26 showRememberSiteToast: vi.fn(),27 showPasskeyAvailable: vi.fn(),28 showPasskeyProGate: vi.fn(),29 showPasskeyRegistrationOffer: vi.fn(),30}));313233vi.mock("./content-ml-detector", () => ({34 contentMLDetector: { predictBatch },35}));3637import { afterEach, beforeEach, describe, expect, it } from "vitest";38import "./_setup";39import { setMessageResponder, resetChromeMock, sentMessages, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";40import { handleFieldInteraction } from "./index";41import { showAutofillSelector, showTOTPSelector } from "./inject";4243const showSelector = showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44const showTOTP = showTOTPSelector as unknown as ReturnType<typeof vi.fn>;454647484950beforeEach(() => {51 document.body.innerHTML = "";52 resetChromeMock();53 showSelector.mockClear();54 showTOTP.mockClear();55 predictBatch.mockReset();56 predictBatch.mockReturnValue(Promise.resolve([]));57});5859afterEach(async () => {60 vi.useRealTimers();61 62 63 64 65 await new Promise((r) => setTimeout(r, 0));66});676869function lastSelectorOptions(): any {70 const calls = showSelector.mock.calls;71 return calls.length ? calls[calls.length - 1]![3] : undefined;72}7374describe("T-F01–T-F02: ML predict timeout race (150ms)", () => {75 76 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); 81 82 await new Promise((r) => setTimeout(r, 20));83 expect(showSelector).not.toHaveBeenCalled();84 });8586 87 88 89 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});102103describe("T-F03–T-F05: vault state + form shape", () => {104 105 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 });115116 117 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 expect(sentMessages.some((msg) => msg.type === "GET_IDENTITIES")).toBe(false);128 });129130 131 132 it("T-F05: email-first form opens on the email field", async () => {133 const { email } = createEmailFirstForm();134 setMessageResponder((msg) =>135 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }136 );137 await handleFieldInteraction(email);138 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());139 });140});141142describe("T-F07: TOTP field path", () => {143 144 145 it("T-F07: standalone one-time-code field → TOTP selector", async () => {146 const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });147 setMessageResponder((msg) =>148 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "t1", title: "Acme 2FA" }] } : { ok: true, data: null }149 );150 await handleFieldInteraction(totp);151 await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());152 expect(showSelector).not.toHaveBeenCalled();153 });154});155156describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => {157 158 159 it("T-F09: form with autocomplete=off still offers", async () => {160 const form = document.createElement("form");161 form.setAttribute("autocomplete", "off");162 document.body.appendChild(form);163 makeInput({ type: "text", name: "username", autocomplete: "username" }, form);164 const password = makeInput({ type: "password", autocomplete: "current-password" }, form);165 setMessageResponder((msg) =>166 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }167 );168 await handleFieldInteraction(password);169 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());170 });171});172173describe("T-F10–T-F12: robustness", () => {174 175 176 177 it("T-F10/T-F12: detached field mid-interaction → clean abort, no throw, no selector", async () => {178 const detached = document.createElement("input");179 detached.type = "password"; 180 await expect(handleFieldInteraction(detached)).resolves.toBeUndefined();181 expect(showSelector).not.toHaveBeenCalled();182 });183184 185 186 it("T-F11: concurrent calls for the same field → selector opens at most once", async () => {187 const { password } = createLoginForm();188 setMessageResponder((msg) =>189 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }190 );191 await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);192 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());193 expect(showSelector).toHaveBeenCalledTimes(1);194 });195});196197describe("PB-26-201: browser-command capability propagation", () => {198 it("passes the capability through form inventory and the selected fill", async () => {199 const { password } = createLoginForm();200 setMessageResponder((msg) => {201 if (msg.type === "AUTOFILL_QUERY") {202 return { ok: true, data: [{ id: "foreign-entry", title: "Foreign", username: "alice" }] };203 }204 if (msg.type === "GET_ENTRY_FOR_FILL") {205 return { ok: true, data: { id: "foreign-entry", title: "Foreign", username: "alice", password: "secret" } };206 }207 return { ok: true, data: null };208 });209210 await handleFieldInteraction(password, true, "form-capability");211 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());212213 expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({214 payload: { includeAll: true, capability: "form-capability" },215 });216 const onSelect = showSelector.mock.calls.at(-1)![2] as (id: string) => Promise<void>;217 await onSelect("foreign-entry");218 expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({219 payload: { id: "foreign-entry", capability: "form-capability" },220 });221 });222223 it("passes the capability through TOTP inventory and the selected fill", async () => {224 const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });225 setMessageResponder((msg) => {226 if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "totp-entry", title: "Foreign 2FA" }] };227 if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "totp-entry", totpCode: "123456" } };228 return { ok: true, data: null };229 });230231 await handleFieldInteraction(totp, true, "totp-capability");232 await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());233 expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({234 payload: { kind: "totp", capability: "totp-capability" },235 });236237 const onSelect = showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>;238 await onSelect("totp-entry");239 expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({240 payload: { id: "totp-entry", capability: "totp-capability" },241 });242 });243});