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 });128129 130 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});140141describe("T-F07: TOTP field path", () => {142 143 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});154155describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => {156 157 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});171172describe("T-F10–T-F12: robustness", () => {173 174 175 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"; 179 await expect(handleFieldInteraction(detached)).resolves.toBeUndefined();180 expect(showSelector).not.toHaveBeenCalled();181 });182183 184 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});195196describe("PB-26-201: browser-command capability propagation", () => {197 it("passes the capability through form inventory and the selected fill", async () => {198 const { password } = createLoginForm();199 setMessageResponder((msg) => {200 if (msg.type === "AUTOFILL_QUERY") {201 return { ok: true, data: [{ id: "foreign-entry", title: "Foreign", username: "alice" }] };202 }203 if (msg.type === "GET_ENTRY_FOR_FILL") {204 return { ok: true, data: { id: "foreign-entry", title: "Foreign", username: "alice", password: "secret" } };205 }206 return { ok: true, data: null };207 });208209 await handleFieldInteraction(password, true, "form-capability");210 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());211212 expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({213 payload: { includeAll: true, capability: "form-capability" },214 });215 const onSelect = showSelector.mock.calls.at(-1)![2] as (id: string) => Promise<void>;216 await onSelect("foreign-entry");217 expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({218 payload: { id: "foreign-entry", capability: "form-capability" },219 });220 });221222 it("passes the capability through TOTP inventory and the selected fill", async () => {223 const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });224 setMessageResponder((msg) => {225 if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "totp-entry", title: "Foreign 2FA" }] };226 if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "totp-entry", totpCode: "123456" } };227 return { ok: true, data: null };228 });229230 await handleFieldInteraction(totp, true, "totp-capability");231 await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());232 expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({233 payload: { kind: "totp", capability: "totp-capability" },234 });235236 const onSelect = showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>;237 await onSelect("totp-entry");238 expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({239 payload: { id: "totp-entry", capability: "totp-capability" },240 });241 });242});