content.field-interaction.test.ts

11.4 KB · raw

1// @vitest-environment jsdom2//3// Test File 2 — index.ts handleFieldInteraction core paths (T-F01–T-F12).4//5// handleFieldInteraction is the central decision: ML predict → suppression6// layers → TOTP/OTP branch → vault query → which surface opens. We mock the7// boundary it talks to — ./inject (so we can see WHICH surface opened) and8// ./content-ml-detector (so we can control predict timing) — and drive the REAL9// exported function with controlled DOM + chrome responses. Nothing about the10// decision logic itself is mocked.1112import { vi } from "vitest";1314// Spies shared with the hoisted vi.mock factories must themselves be hoisted.15const { predictBatch, dismissSpy } = vi.hoisted(() => ({16  predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),17  dismissSpy: vi.fn(),18}));1920// Observe which inject surface opens. showAutofillSelector returns a dismiss fn.21vi.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}));3132// Control ML predict timing/result per test.33vi.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>;4546// Reset module-private suppression state between tests by exercising the public47// surface: a fresh DOM + cleared mocks. handleFieldInteraction reads live DOM48// each call, so a clean body is a clean slate for everything except the global49// dismiss lock — which we never trip in this file (no dismissals here).50beforeEach(() => {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  // index.ts installs a url-watcher MutationObserver on document.body that62  // reads location.href. Clearing the DOM each test queues its callback; flush63  // it here so it runs while jsdom's globals still exist, instead of throwing64  // "location is not defined" during environment teardown.65  await new Promise((r) => setTimeout(r, 0));66});6768// Last options object passed to showAutofillSelector (4th arg).69function 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  // T-F02: when ML resolves IN TIME with a non-credential verdict for a text76  // field, handleFieldInteraction suppresses — the selector must NOT open.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); // passive (explicitSummon=false)81    // give any stray microtasks a tick82    await new Promise((r) => setTimeout(r, 20));83    expect(showSelector).not.toHaveBeenCalled();84  });8586  // T-F01: when ML is SLOWER than the 150ms cap, the timeout wins, the87  // prediction is discarded (treated as null), and the heuristic path proceeds —88  // so the very same suppressing verdict no longer blocks the selector. This is89  // the regression that froze autofill on pages with a slow ML worker.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  // T-F03: a locked vault shows the inline unlock prompt (showAutofillSelector105  // with {locked:true}), never the entry list.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  // T-F04: unlocked vault with no entries for this domain → signup/identity117  // surface (isSignup:true), not a credential list.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    expect(sentMessages.some((msg) => msg.type === "GET_IDENTITIES")).toBe(false);128  });129130  // T-F05: an email-first form (no password on this step) still opens on the131  // email field — the path must not bail just because there is no password.132  it("T-F05: email-first form opens on the email field", async () => {133    const { email } = createEmailFirstForm();134    setMessageResponder((msg) =>135      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }136    );137    await handleFieldInteraction(email);138    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());139  });140});141142describe("T-F07: TOTP field path", () => {143  // T-F07: a lone verification-code field (no login form present) routes to the144  // TOTP selector, not the credential selector.145  it("T-F07: standalone one-time-code field → TOTP selector", async () => {146    const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });147    setMessageResponder((msg) =>148      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "t1", title: "Acme 2FA" }] } : { ok: true, data: null }149    );150    await handleFieldInteraction(totp);151    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());152    expect(showSelector).not.toHaveBeenCalled();153  });154});155156describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => {157  // T-F09: autocomplete="off" on the form must not stop Peach offering — the158  // user installed Peach precisely to override the site's opt-out.159  it("T-F09: form with autocomplete=off still offers", async () => {160    const form = document.createElement("form");161    form.setAttribute("autocomplete", "off");162    document.body.appendChild(form);163    makeInput({ type: "text", name: "username", autocomplete: "username" }, form);164    const password = makeInput({ type: "password", autocomplete: "current-password" }, form);165    setMessageResponder((msg) =>166      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }167    );168    await handleFieldInteraction(password);169    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());170  });171});172173describe("T-F10–T-F12: robustness", () => {174  // T-F10: a field whose ownerDocument is a different (cross-origin-style)175  // context must be handled without crashing. We approximate by anchoring a176  // detached input — the function must abort cleanly, never throw.177  it("T-F10/T-F12: detached field mid-interaction → clean abort, no throw, no selector", async () => {178    const detached = document.createElement("input");179    detached.type = "password"; // never attached → isVisible() false180    await expect(handleFieldInteraction(detached)).resolves.toBeUndefined();181    expect(showSelector).not.toHaveBeenCalled();182  });183184  // T-F11: two concurrent calls for the SAME field (focusin + icon click racing)185  // must not double-open — pendingInteractions debounces the second.186  it("T-F11: concurrent calls for the same field → selector opens at most once", async () => {187    const { password } = createLoginForm();188    setMessageResponder((msg) =>189      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }190    );191    await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);192    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());193    expect(showSelector).toHaveBeenCalledTimes(1);194  });195});196197describe("PB-26-201: browser-command capability propagation", () => {198  it("passes the capability through form inventory and the selected fill", async () => {199    const { password } = createLoginForm();200    setMessageResponder((msg) => {201      if (msg.type === "AUTOFILL_QUERY") {202        return { ok: true, data: [{ id: "foreign-entry", title: "Foreign", username: "alice" }] };203      }204      if (msg.type === "GET_ENTRY_FOR_FILL") {205        return { ok: true, data: { id: "foreign-entry", title: "Foreign", username: "alice", password: "secret" } };206      }207      return { ok: true, data: null };208    });209210    await handleFieldInteraction(password, true, "form-capability");211    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());212213    expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({214      payload: { includeAll: true, capability: "form-capability" },215    });216    const onSelect = showSelector.mock.calls.at(-1)![2] as (id: string) => Promise<void>;217    await onSelect("foreign-entry");218    expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({219      payload: { id: "foreign-entry", capability: "form-capability" },220    });221  });222223  it("passes the capability through TOTP inventory and the selected fill", async () => {224    const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });225    setMessageResponder((msg) => {226      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "totp-entry", title: "Foreign 2FA" }] };227      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "totp-entry", totpCode: "123456" } };228      return { ok: true, data: null };229    });230231    await handleFieldInteraction(totp, true, "totp-capability");232    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());233    expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({234      payload: { kind: "totp", capability: "totp-capability" },235    });236237    const onSelect = showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>;238    await onSelect("totp-entry");239    expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({240      payload: { id: "totp-entry", capability: "totp-capability" },241    });242  });243});