content.dismissal.test.ts

11.8 KB · raw

1// @vitest-environment jsdom2//3// Test File 1 — index.ts 3-layer dismissal model (T-D01–T-D15).4//5// This is the most regression-prone logic in the project (v1.4.14 broke it6// repeatedly). Three independent layers each prevent the selector reopening:7//   Layer 1 — lastDismissedField: per-field lock until focus genuinely leaves.8//   Layer 2 — dismissLock (DISMISS_LOCK_MS=800): global post-dismiss cooldown.9//   Layer 3 — wasAlreadyFilled: filledForms (form-level, permanent) + pageFilledAt10//             (page-level, PAGE_FILL_GRACE_MS=3000 window).11//12// All three short-circuit handleFieldInteraction BEFORE the ML race, so we drive13// them through the exported seams: _testSeam (read/write lastDismissedField,14// read dismissLock), the captured onDismiss callback (to fire a real dismiss →15// startDismissLock), and the captured onFillFields callback (to fire a real fill16// → recordFillSuccess). Fake timers are used ONLY for the expiry windows, per17// the brief's quality bar.1819import { 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  // index.ts has no public reset for pageFilledAt, but its url-watcher observer64  // nulls it whenever location.href changes. Push a fresh URL and mutate the65  // body so that observer fires — giving every test a clean fill-grace slate.66  try {67    history.pushState({}, "", `/t${++urlCounter}`);68  } catch { /* jsdom history unavailable — fall through */ }69  document.body.innerHTML = "";70  await new Promise((r) => setTimeout(r, 0)); // let the url-watcher observer run7172  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)); // flush index.ts's url-watcher observer83});8485// ── Layer 1 — per-field lock (lastDismissedField) ────────────────────────────86describe("T-D01–T-D04: Layer 1 per-field lock", () => {87  // T-D01: a field whose selector was dismissed must not reopen on re-focus.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); // simulate "user pressed Escape on this field"92    await handleFieldInteraction(password);93    await new Promise((r) => setTimeout(r, 20));94    expect(showSelector).not.toHaveBeenCalled();95  });9697  // T-D02 / T-D04: the lock is field-specific — focusing a DIFFERENT field opens,98  // and doing so clears the stale per-field lock.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(); // stale lock cleared106  });107108  // T-D03: once the per-field lock is cleared (focus moved away and back), the109  // same field opens again.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});118119// ── Layer 2 — global cooldown (DISMISS_LOCK_MS) ──────────────────────────────120describe("T-D05–T-D07: Layer 2 post-dismiss global cooldown", () => {121  // T-D05: immediately after a dismiss, focusing ANY other field is blocked by122  // the global lock — not just the dismissed field.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(); // real dismiss → startDismissLock()132    expect(_testSeam.isDismissLocked()).toBe(true);133134    showSelector.mockClear();135    await handleFieldInteraction(username); // different field, but cooldown active136    expect(showSelector).not.toHaveBeenCalled();137138    // T-D06: after DISMISS_LOCK_MS the global lock clears.139    vi.advanceTimersByTime(_testSeam.DISMISS_LOCK_MS + 1);140    expect(_testSeam.isDismissLocked()).toBe(false);141  });142143  // T-D06: with the cooldown expired, a fresh focus opens normally again.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    // New field after expiry → opens. (lastDismissedField was set to the old158    // password element; a different element is unaffected.)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  // T-D07: multiple rapid focuses during the cooldown all suppress — only the167  // original (pre-dismiss) open ever happened.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});185186// ── Layer 3 — fill-success grace (wasAlreadyFilled) ──────────────────────────187describe("T-D08–T-D12: Layer 3 fill-success grace", () => {188  // Drive a real fill by capturing the onFillFields callback the selector was189  // opened with and invoking it — that calls recordFillSuccess internally.190  async function openAndFill() {191    const { form, username, password } = createLoginForm();192    // jsdom's default document URL is http, and PB-26-008 refuses to fill any193    // form whose action is not https on the page domain — an unactioned form194    // inherits the document URL, so the fixture must declare one.195    form.action = "https://localhost/login";196    withEntries();197    await handleFieldInteraction(password);198    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());199    const onFill = lastOptions().onFillFields as (u: string, p: string, e?: string) => void;200    onFill("alice", "s3cret");201    return { form, username, password };202  }203204  // T-D08 / T-D11: immediately after a fill, both the form-level and page-level205  // checks report "already filled".206  it("T-D08/T-D11: after fill, wasAlreadyFilled is true (form-level and page-level)", async () => {207    const { password } = await openAndFill();208    expect(wasAlreadyFilled(password)).toBe(true); // form-level (filledForms)209    const formless = makeInput({ type: "password" }); // not in a <form>210    expect(wasAlreadyFilled(formless)).toBe(true); // page-level grace window211  });212213  // T-D09 / T-D12: once the page grace window expires, a formless field is no214  // longer suppressed (a field still inside the filled form stays locked — that215  // is the permanent form-level guard, documented below).216  it("T-D09/T-D12: after the grace window, a formless field is no longer suppressed", async () => {217    await openAndFill();218    const formless = makeInput({ type: "password" });219    expect(wasAlreadyFilled(formless)).toBe(true);220    vi.useFakeTimers();221    vi.advanceTimersByTime(5000); // well past PAGE_FILL_GRACE_MS222    expect(wasAlreadyFilled(formless)).toBe(false);223    vi.useRealTimers();224  });225226  // T-D10: the brief expected the grace to be form-scoped (form B opens during227  // form A's grace). The implementation's page-level grace is NOT form-scoped:228  // for PAGE_FILL_GRACE_MS, EVERY field is suppressed. After the window, the229  // filled form A's fields stay locked (filledForms is permanent) while a field230  // in an unrelated form B opens. This test pins the actual behavior.231  it("T-D10: during the page grace every field is suppressed; after it, only the filled form stays locked", async () => {232    const { password: aPassword } = await openAndFill();233    // Build a second, independent form B.234    const formB = document.createElement("form");235    document.body.appendChild(formB);236    const bPassword = makeInput({ type: "password", autocomplete: "current-password" }, formB);237238    // During grace: B is suppressed too (page-level grace is global).239    expect(wasAlreadyFilled(bPassword)).toBe(true);240241    vi.useFakeTimers();242    vi.advanceTimersByTime(5000);243    // After grace: A's form is permanently locked, B is free.244    expect(wasAlreadyFilled(aPassword)).toBe(true); // filledForms (permanent)245    expect(wasAlreadyFilled(bPassword)).toBe(false);246    vi.useRealTimers();247  });248});249250// ── Edge cases ────────────────────────────────────────────────────────────────251describe("T-D13: concurrent interaction", () => {252  // T-D13: a second concurrent interaction on the same field is debounced by253  // pendingInteractions, so a rapid focus/click pair cannot double-open (the254  // visible "flicker" regression). (Mirrors T-F11 from the dedicated angle.)255  it("T-D13: concurrent interactions on one field open it at most once (no flicker)", async () => {256    const { password } = createLoginForm();257    withEntries();258    await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);259    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());260    expect(showSelector).toHaveBeenCalledTimes(1);261  });262263});