content.dismissal.test.ts

12.2 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    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  // T-D08 / T-D11: immediately after a fill, both the form-level and page-level201  // checks report "already filled".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); // form-level (filledForms)205    const formless = makeInput({ type: "password" }); // not in a <form>206    expect(wasAlreadyFilled(formless)).toBe(true); // page-level grace window207  });208209  // T-D09 / T-D12: once the page grace window expires, a formless field is no210  // longer suppressed (a field still inside the filled form stays locked — that211  // is the permanent form-level guard, documented below).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); // well past PAGE_FILL_GRACE_MS218    expect(wasAlreadyFilled(formless)).toBe(false);219    vi.useRealTimers();220  });221222  // T-D10: the brief expected the grace to be form-scoped (form B opens during223  // form A's grace). The implementation's page-level grace is NOT form-scoped:224  // for PAGE_FILL_GRACE_MS, EVERY field is suppressed. After the window, the225  // filled form A's fields stay locked (filledForms is permanent) while a field226  // in an unrelated form B opens. We pin the ACTUAL behavior. See227  // INTEGRATION-CONTRACT.md.228  it("T-D10: during the page grace every field is suppressed; after it, only the filled form stays locked", async () => {229    const { password: aPassword } = await openAndFill();230    // Build a second, independent form B.231    const formB = document.createElement("form");232    document.body.appendChild(formB);233    const bPassword = makeInput({ type: "password", autocomplete: "current-password" }, formB);234235    // During grace: B is suppressed too (page-level grace is global).236    expect(wasAlreadyFilled(bPassword)).toBe(true);237238    vi.useFakeTimers();239    vi.advanceTimersByTime(5000);240    // After grace: A's form is permanently locked, B is free.241    expect(wasAlreadyFilled(aPassword)).toBe(true); // filledForms (permanent)242    expect(wasAlreadyFilled(bPassword)).toBe(false);243    vi.useRealTimers();244  });245});246247// ── Edge cases (T-D13–T-D15) ─────────────────────────────────────────────────248describe("T-D13–T-D15: edge cases and documented boundaries", () => {249  // T-D13: a second concurrent interaction on the same field is debounced by250  // pendingInteractions, so a rapid focus/click pair cannot double-open (the251  // visible "flicker" regression). (Mirrors T-F11 from the dedicated angle.)252  it("T-D13: concurrent interactions on one field open it at most once (no flicker)", async () => {253    const { password } = createLoginForm();254    withEntries();255    await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);256    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());257    expect(showSelector).toHaveBeenCalledTimes(1);258  });259260  // T-D14 (scroll-into-view) and T-D15 (isTrusted/programmatic focus): these are261  // not decisions made inside handleFieldInteraction — the function is gesture-262  // agnostic and is invoked by the focusin listener regardless of isTrusted.263  // They belong to the Playwright e2e layer (real user gestures vs synthetic).264  // Documented in INTEGRATION-CONTRACT.md rather than asserted with a false265  // unit-level proxy that would always pass.266  it.todo("T-D14: focus after scroll-into-view opens normally (e2e — real gesture)");267  it.todo("T-D15: programmatic focus (isTrusted=false) suppressed (e2e — real gesture)");268});