content.initial-scan.test.ts

5.8 KB · raw

1// @vitest-environment jsdom2//3// Test File 3 — index.ts initialScan aggression levels (T-S01–T-S10).4//5// initialScan decides where the field icon is injected at page load. Aggression6// climbs with each pass: level 0 only touches pages with a recognised credential7// form; level 2 final-pass injects on a bare password field even without8// surrounding form context (SPA reveal-after-username case). We mock ./inject so9// injectFieldIcon is observable, and assert on its call count — the real10// regression surface is "icon missing" or "icon on the wrong field".1112import { vi } from "vitest";1314const { injectIcon } = vi.hoisted(() => ({ injectIcon: vi.fn(() => vi.fn()) }));1516vi.mock("./inject", () => ({17  injectFieldIcon: injectIcon,18  showAutofillSelector: vi.fn(() => vi.fn()),19  showTOTPSelector: vi.fn(() => vi.fn()),20  showCardSelector: vi.fn(() => vi.fn()),21  showRememberSiteToast: vi.fn(),22  showPasskeyAvailable: vi.fn(),23  showPasskeyProGate: vi.fn(),24  showPasskeyRegistrationOffer: vi.fn(),25}));26vi.mock("./content-ml-detector", () => ({27  contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) },28}));2930import { afterEach, beforeEach, describe, expect, it } from "vitest";31import "./_setup";32import { resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";33import { initialScan } from "./index";3435// Each test uses fresh DOM elements, so the module-private processedInputs36// WeakSet never collides across tests; injectIcon's count is therefore a clean37// per-test signal.38beforeEach(() => {39  document.body.innerHTML = "";40  resetChromeMock();41  injectIcon.mockClear();42});4344afterEach(async () => {45  await new Promise((r) => setTimeout(r, 0));46});4748describe("T-S01–T-S02: level 0 is conservative", () => {49  // T-S01: an empty page → scan runs, injects nothing.50  it("T-S01: no forms at all + level 0 → no icon", () => {51    initialScan(0);52    expect(injectIcon).not.toHaveBeenCalled();53  });5455  // T-S02: a non-credential form (search box) → level 0 injects nothing.56  it("T-S02: non-matching form + level 0 → no icon", () => {57    const f = document.createElement("form");58    document.body.appendChild(f);59    makeInput({ type: "search", name: "q", placeholder: "Search" }, f);60    initialScan(0);61    expect(injectIcon).not.toHaveBeenCalled();62  });63});6465describe("T-S03–T-S06: icons get injected as aggression rises", () => {66  // T-S03: a standard login form at level 1 → icon injected (on the credential67  // fields).68  it("T-S03: standard login form + level 1 → icon injected", () => {69    createLoginForm();70    initialScan(1);71    expect(injectIcon).toHaveBeenCalled();72  });7374  // T-S04: an email-first form (email only) → icon injected on the email field.75  it("T-S04: email-first form + level 1 → icon injected", () => {76    createEmailFirstForm();77    initialScan(1);78    expect(injectIcon).toHaveBeenCalled();79  });8081  // T-S05: a bare password field (no username) at level 2 final-pass → icon82  // injected. This is the SPA case where the password appears after the83  // username step; without the final pass the field would never get an icon.84  it("T-S05: bare password + level 2 final-pass → icon injected", () => {85    makeInput({ type: "password", autocomplete: "current-password" });86    initialScan(2, true);87    expect(injectIcon).toHaveBeenCalled();88  });8990  // T-S07: multiple independent login forms → each qualifying password field91  // gets its own icon.92  it("T-S07: two login forms → an icon per form's password field", () => {93    createLoginForm();94    createLoginForm();95    initialScan(1);96    // At least two injections — one per password field. (Username fields may add97    // more; the floor is the two passwords.)98    expect(injectIcon.mock.calls.length).toBeGreaterThanOrEqual(2);99  });100});101102describe("T-S08–T-S10: visibility, dynamic forms, idempotency", () => {103  // T-S08: a display:none form is not a fillable surface — no icon, even at104  // level 1, because isVisible() rejects its fields.105  it("T-S08: hidden (display:none) form → no icon", () => {106    const f = document.createElement("form");107    f.style.display = "none";108    document.body.appendChild(f);109    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f);110    const p = makeInput({ type: "password", autocomplete: "current-password" }, f);111    // Force isVisible() false by zeroing the rects (display:none isn't computed112    // for ancestors in jsdom, so neutralise the per-field rect shim too).113    for (const el of [u, p]) {114      Object.defineProperty(el, "getBoundingClientRect", {115        configurable: true,116        value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }),117      });118    }119    initialScan(1);120    expect(injectIcon).not.toHaveBeenCalled();121  });122123  // T-S09: a form inserted after the first scan gets an icon when the scan runs124  // again (the MutationObserver path re-invokes the reattach logic; here we125  // drive the rescan directly to keep the test deterministic — the debounce126  // timing itself is covered by the Playwright e2e).127  it("T-S09: a form inserted after the first scan is picked up on rescan", () => {128    initialScan(1);129    expect(injectIcon).not.toHaveBeenCalled(); // nothing on the page yet130    createLoginForm(); // dynamic insertion131    initialScan(1);132    expect(injectIcon).toHaveBeenCalled();133  });134135  // T-S10: running the scan twice on the same form injects exactly once —136  // processedInputs makes it idempotent, preventing duplicate stacked icons.137  it("T-S10: scanning the same form twice → icon injected once per field", () => {138    createLoginForm();139    initialScan(1);140    const afterFirst = injectIcon.mock.calls.length;141    initialScan(1);142    expect(injectIcon.mock.calls.length).toBe(afterFirst); // no new injections143  });144});