12345678910111213141516171819import { vi } from "vitest";2021const { predictBatch, dismissSpy } = vi.hoisted(() => ({22 predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),23 dismissSpy: vi.fn(),24}));2526vi.mock("./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}));36vi.mock("./content-ml-detector", () => ({37 contentMLDetector: { predictBatch },38}));3940import { afterEach, beforeEach, describe, expect, it } from "vitest";41import "./_setup";42import { resetChromeMock, setMessageResponder, makeInput, createLoginForm } from "./_setup";43import { handleFieldInteraction, wasAlreadyFilled, _testSeam } from "./index";44import { showAutofillSelector } from "./inject";4546const showSelector = showAutofillSelector as unknown as ReturnType<typeof vi.fn>;47const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];4849function lastOptions(): any {50 const calls = showSelector.mock.calls;51 return calls.length ? calls[calls.length - 1]![3] : undefined;52}5354function withEntries(): void {55 setMessageResponder((msg) =>56 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }57 );58}5960let urlCounter = 0;6162beforeEach(async () => {63 64 65 66 try {67 history.pushState({}, "", `/t${++urlCounter}`);68 } catch { }69 document.body.innerHTML = "";70 await new Promise((r) => setTimeout(r, 0)); 7172 resetChromeMock();73 showSelector.mockClear();74 dismissSpy.mockClear();75 predictBatch.mockReset();76 predictBatch.mockReturnValue(Promise.resolve([]));77 _testSeam.setLastDismissedField(null);78});7980afterEach(async () => {81 vi.useRealTimers();82 await new Promise((r) => setTimeout(r, 0)); 83});848586describe("T-D01–T-D04: Layer 1 per-field lock", () => {87 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); 92 await handleFieldInteraction(password);93 await new Promise((r) => setTimeout(r, 20));94 expect(showSelector).not.toHaveBeenCalled();95 });9697 98 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(); 106 });107108 109 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});118119120describe("T-D05–T-D07: Layer 2 post-dismiss global cooldown", () => {121 122 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());128129 const onDismiss = lastOptions().onDismiss as () => void;130 vi.useFakeTimers();131 onDismiss(); 132 expect(_testSeam.isDismissLocked()).toBe(true);133134 showSelector.mockClear();135 await handleFieldInteraction(username); 136 expect(showSelector).not.toHaveBeenCalled();137138 139 vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);140 expect(_testSeam.isDismissLocked()).toBe(false);141 });142143 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;150151 vi.useFakeTimers();152 onDismiss();153 vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);154 expect(_testSeam.isDismissLocked()).toBe(false);155 vi.useRealTimers();156157 158 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 });165166 167 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;174175 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});185186187describe("T-D08–T-D12: Layer 3 fill-success grace", () => {188 189 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 }199200 201 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); 205 const formless = makeInput({ type: "password" }); 206 expect(wasAlreadyFilled(formless)).toBe(true); 207 });208209 210 211 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); 218 expect(wasAlreadyFilled(formless)).toBe(false);219 vi.useRealTimers();220 });221222 223 224 225 226 227 it("T-D10: during the page grace every field is suppressed; after it, only the filled form stays locked", async () => {228 const { password: aPassword } = await openAndFill();229 230 const formB = document.createElement("form");231 document.body.appendChild(formB);232 const bPassword = makeInput({ type: "password", autocomplete: "current-password" }, formB);233234 235 expect(wasAlreadyFilled(bPassword)).toBe(true);236237 vi.useFakeTimers();238 vi.advanceTimersByTime(5000);239 240 expect(wasAlreadyFilled(aPassword)).toBe(true); 241 expect(wasAlreadyFilled(bPassword)).toBe(false);242 vi.useRealTimers();243 });244});245246247describe("T-D13: concurrent interaction", () => {248 249 250 251 it("T-D13: concurrent interactions on one field open it at most once (no flicker)", async () => {252 const { password } = createLoginForm();253 withEntries();254 await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);255 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());256 expect(showSelector).toHaveBeenCalledTimes(1);257 });258259});