// @vitest-environment jsdom // // Test File 1 — index.ts 3-layer dismissal model (T-D01–T-D15). // // This is the most regression-prone logic in the project (v1.4.14 broke it // repeatedly). Three independent layers each prevent the selector reopening: // Layer 1 — lastDismissedField: per-field lock until focus genuinely leaves. // Layer 2 — dismissLock (DISMISS_LOCK_MS=800): global post-dismiss cooldown. // Layer 3 — wasAlreadyFilled: filledForms (form-level, permanent) + pageFilledAt // (page-level, PAGE_FILL_GRACE_MS=3000 window). // // All three short-circuit handleFieldInteraction BEFORE the ML race, so we drive // them through the exported seams: _testSeam (read/write lastDismissedField, // read dismissLock), the captured onDismiss callback (to fire a real dismiss → // startDismissLock), and the captured onFillFields callback (to fire a real fill // → recordFillSuccess). Fake timers are used ONLY for the expiry windows, per // the brief's quality bar. import { vi } from "vitest"; const { predictBatch, dismissSpy } = vi.hoisted(() => ({ predictBatch: vi.fn(() => Promise.resolve([] as unknown[])), dismissSpy: vi.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(), })); vi.mock("./content-ml-detector", () => ({ contentMLDetector: { predictBatch }, })); import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./_setup"; import { resetChromeMock, setMessageResponder, makeInput, createLoginForm } from "./_setup"; import { handleFieldInteraction, wasAlreadyFilled, _testSeam } from "./index"; import { showAutofillSelector } from "./inject"; const showSelector = showAutofillSelector as unknown as ReturnType; const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }]; function lastOptions(): any { const calls = showSelector.mock.calls; return calls.length ? calls[calls.length - 1]![3] : undefined; } function withEntries(): void { setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null } ); } let urlCounter = 0; beforeEach(async () => { // index.ts has no public reset for pageFilledAt, but its url-watcher observer // nulls it whenever location.href changes. Push a fresh URL and mutate the // body so that observer fires — giving every test a clean fill-grace slate. try { history.pushState({}, "", `/t${++urlCounter}`); } catch { /* jsdom history unavailable — fall through */ } document.body.innerHTML = ""; await new Promise((r) => setTimeout(r, 0)); // let the url-watcher observer run resetChromeMock(); showSelector.mockClear(); dismissSpy.mockClear(); predictBatch.mockReset(); predictBatch.mockReturnValue(Promise.resolve([])); _testSeam.setLastDismissedField(null); }); afterEach(async () => { vi.useRealTimers(); await new Promise((r) => setTimeout(r, 0)); // flush index.ts's url-watcher observer }); // ── Layer 1 — per-field lock (lastDismissedField) ──────────────────────────── describe("T-D01–T-D04: Layer 1 per-field lock", () => { // T-D01: a field whose selector was dismissed must not reopen on re-focus. it("T-D01: re-focusing the dismissed field does NOT reopen the selector", async () => { const { password } = createLoginForm(); withEntries(); _testSeam.setLastDismissedField(password); // simulate "user pressed Escape on this field" await handleFieldInteraction(password); await new Promise((r) => setTimeout(r, 20)); expect(showSelector).not.toHaveBeenCalled(); }); // T-D02 / T-D04: the lock is field-specific — focusing a DIFFERENT field opens, // and doing so clears the stale per-field lock. it("T-D02/T-D04: a different field opens (lock is field-specific)", async () => { const { username, password } = createLoginForm(); withEntries(); _testSeam.setLastDismissedField(username); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(_testSeam.getLastDismissedField()).toBeNull(); // stale lock cleared }); // T-D03: once the per-field lock is cleared (focus moved away and back), the // same field opens again. it("T-D03: clearing the lock lets the same field open again", async () => { const { password } = createLoginForm(); withEntries(); _testSeam.setLastDismissedField(null); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); }); }); // ── Layer 2 — global cooldown (DISMISS_LOCK_MS) ────────────────────────────── describe("T-D05–T-D07: Layer 2 post-dismiss global cooldown", () => { // T-D05: immediately after a dismiss, focusing ANY other field is blocked by // the global lock — not just the dismissed field. it("T-D05: dismiss → focus a different field → blocked by global cooldown", async () => { const { username, password } = createLoginForm(); withEntries(); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); const onDismiss = lastOptions().onDismiss as () => void; vi.useFakeTimers(); onDismiss(); // real dismiss → startDismissLock() expect(_testSeam.isDismissLocked()).toBe(true); showSelector.mockClear(); await handleFieldInteraction(username); // different field, but cooldown active expect(showSelector).not.toHaveBeenCalled(); // T-D06: after DISMISS_LOCK_MS the global lock clears. vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1); expect(_testSeam.isDismissLocked()).toBe(false); }); // T-D06: with the cooldown expired, a fresh focus opens normally again. it("T-D06: after the cooldown expires, the selector opens again", async () => { const { password } = createLoginForm(); withEntries(); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); const onDismiss = lastOptions().onDismiss as () => void; vi.useFakeTimers(); onDismiss(); vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1); expect(_testSeam.isDismissLocked()).toBe(false); vi.useRealTimers(); // New field after expiry → opens. (lastDismissedField was set to the old // password element; a different element is unaffected.) _testSeam.setLastDismissedField(null); const fresh = makeInput({ type: "password", autocomplete: "current-password" }); showSelector.mockClear(); await handleFieldInteraction(fresh); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); }); // T-D07: multiple rapid focuses during the cooldown all suppress — only the // original (pre-dismiss) open ever happened. it("T-D07: rapid focus events during cooldown are all suppressed", async () => { const { username, password } = createLoginForm(); withEntries(); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); const onDismiss = lastOptions().onDismiss as () => void; vi.useFakeTimers(); onDismiss(); showSelector.mockClear(); await handleFieldInteraction(username); await handleFieldInteraction(password); await handleFieldInteraction(username); expect(showSelector).not.toHaveBeenCalled(); vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1); }); }); // ── Layer 3 — fill-success grace (wasAlreadyFilled) ────────────────────────── describe("T-D08–T-D12: Layer 3 fill-success grace", () => { // Drive a real fill by capturing the onFillFields callback the selector was // opened with and invoking it — that calls recordFillSuccess internally. async function openAndFill() { const { form, username, password } = createLoginForm(); withEntries(); await handleFieldInteraction(password); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); const onFill = lastOptions().onFillFields as (u: string, p: string, e?: string) => void; onFill("alice", "s3cret"); return { form, username, password }; } // T-D08 / T-D11: immediately after a fill, both the form-level and page-level // checks report "already filled". it("T-D08/T-D11: after fill, wasAlreadyFilled is true (form-level and page-level)", async () => { const { password } = await openAndFill(); expect(wasAlreadyFilled(password)).toBe(true); // form-level (filledForms) const formless = makeInput({ type: "password" }); // not in a
expect(wasAlreadyFilled(formless)).toBe(true); // page-level grace window }); // T-D09 / T-D12: once the page grace window expires, a formless field is no // longer suppressed (a field still inside the filled form stays locked — that // is the permanent form-level guard, documented below). it("T-D09/T-D12: after the grace window, a formless field is no longer suppressed", async () => { await openAndFill(); const formless = makeInput({ type: "password" }); expect(wasAlreadyFilled(formless)).toBe(true); vi.useFakeTimers(); vi.advanceTimersByTime(5000); // well past PAGE_FILL_GRACE_MS expect(wasAlreadyFilled(formless)).toBe(false); vi.useRealTimers(); }); // T-D10: the brief expected the grace to be form-scoped (form B opens during // form A's grace). The implementation's page-level grace is NOT form-scoped: // for PAGE_FILL_GRACE_MS, EVERY field is suppressed. After the window, the // filled form A's fields stay locked (filledForms is permanent) while a field // in an unrelated form B opens. This test pins the actual behavior. it("T-D10: during the page grace every field is suppressed; after it, only the filled form stays locked", async () => { const { password: aPassword } = await openAndFill(); // Build a second, independent form B. const formB = document.createElement("form"); document.body.appendChild(formB); const bPassword = makeInput({ type: "password", autocomplete: "current-password" }, formB); // During grace: B is suppressed too (page-level grace is global). expect(wasAlreadyFilled(bPassword)).toBe(true); vi.useFakeTimers(); vi.advanceTimersByTime(5000); // After grace: A's form is permanently locked, B is free. expect(wasAlreadyFilled(aPassword)).toBe(true); // filledForms (permanent) expect(wasAlreadyFilled(bPassword)).toBe(false); vi.useRealTimers(); }); }); // ── Edge cases ──────────────────────────────────────────────────────────────── describe("T-D13: concurrent interaction", () => { // T-D13: a second concurrent interaction on the same field is debounced by // pendingInteractions, so a rapid focus/click pair cannot double-open (the // visible "flicker" regression). (Mirrors T-F11 from the dedicated angle.) it("T-D13: concurrent interactions on one field open it at most once (no flicker)", async () => { const { password } = createLoginForm(); withEntries(); await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(showSelector).toHaveBeenCalledTimes(1); }); });