content.field-interaction.test.ts

9.1 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, 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  });128129  // T-F05: an email-first form (no password on this step) still opens on the130  // email field — the path must not bail just because there is no password.131  it("T-F05: email-first form opens on the email field", async () => {132    const { email } = createEmailFirstForm();133    setMessageResponder((msg) =>134      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }135    );136    await handleFieldInteraction(email);137    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());138  });139});140141describe("T-F07: TOTP field path", () => {142  // T-F07: a lone verification-code field (no login form present) routes to the143  // TOTP selector, not the credential selector.144  it("T-F07: standalone one-time-code field → TOTP selector", async () => {145    const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });146    setMessageResponder((msg) =>147      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "t1", title: "Acme 2FA" }] } : { ok: true, data: null }148    );149    await handleFieldInteraction(totp);150    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());151    expect(showSelector).not.toHaveBeenCalled();152  });153});154155describe("T-F09: Peach respects the user, not the page's autocomplete=off", () => {156  // T-F09: autocomplete="off" on the form must not stop Peach offering — the157  // user installed Peach precisely to override the site's opt-out.158  it("T-F09: form with autocomplete=off still offers", async () => {159    const form = document.createElement("form");160    form.setAttribute("autocomplete", "off");161    document.body.appendChild(form);162    makeInput({ type: "text", name: "username", autocomplete: "username" }, form);163    const password = makeInput({ type: "password", autocomplete: "current-password" }, form);164    setMessageResponder((msg) =>165      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }166    );167    await handleFieldInteraction(password);168    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());169  });170});171172describe("T-F10–T-F12: robustness", () => {173  // T-F10: a field whose ownerDocument is a different (cross-origin-style)174  // context must be handled without crashing. We approximate by anchoring a175  // detached input — the function must abort cleanly, never throw.176  it("T-F10/T-F12: detached field mid-interaction → clean abort, no throw, no selector", async () => {177    const detached = document.createElement("input");178    detached.type = "password"; // never attached → isVisible() false179    await expect(handleFieldInteraction(detached)).resolves.toBeUndefined();180    expect(showSelector).not.toHaveBeenCalled();181  });182183  // T-F11: two concurrent calls for the SAME field (focusin + icon click racing)184  // must not double-open — pendingInteractions debounces the second.185  it("T-F11: concurrent calls for the same field → selector opens at most once", async () => {186    const { password } = createLoginForm();187    setMessageResponder((msg) =>188      msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "e1", title: "Acme", username: "alice" }] } : { ok: true, data: null }189    );190    await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);191    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());192    expect(showSelector).toHaveBeenCalledTimes(1);193  });194});