// @vitest-environment jsdom // // Test File 3 — index.ts initialScan aggression levels (T-S01–T-S10). // // initialScan decides where the field icon is injected at page load. Aggression // climbs with each pass: level 0 only touches pages with a recognised credential // form; level 2 final-pass injects on a bare password field even without // surrounding form context (SPA reveal-after-username case). We mock ./inject so // injectFieldIcon is observable, and assert on its call count — the real // regression surface is "icon missing" or "icon on the wrong field". import { vi } from "vitest"; const { injectIcon } = vi.hoisted(() => ({ injectIcon: vi.fn(() => vi.fn()) })); vi.mock("./inject", () => ({ injectFieldIcon: injectIcon, showAutofillSelector: vi.fn(() => vi.fn()), showTOTPSelector: vi.fn(() => vi.fn()), showCardSelector: vi.fn(() => vi.fn()), showRememberSiteToast: vi.fn(), showPasskeyAvailable: vi.fn(), showPasskeyProGate: vi.fn(), showPasskeyRegistrationOffer: vi.fn(), })); vi.mock("./content-ml-detector", () => ({ contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) }, })); import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./_setup"; import { resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup"; import { initialScan } from "./index"; // Each test uses fresh DOM elements, so the module-private processedInputs // WeakSet never collides across tests; injectIcon's count is therefore a clean // per-test signal. beforeEach(() => { document.body.innerHTML = ""; resetChromeMock(); injectIcon.mockClear(); }); afterEach(async () => { await new Promise((r) => setTimeout(r, 0)); }); describe("T-S01–T-S02: level 0 is conservative", () => { // T-S01: an empty page → scan runs, injects nothing. it("T-S01: no forms at all + level 0 → no icon", () => { initialScan(0); expect(injectIcon).not.toHaveBeenCalled(); }); // T-S02: a non-credential form (search box) → level 0 injects nothing. it("T-S02: non-matching form + level 0 → no icon", () => { const f = document.createElement("form"); document.body.appendChild(f); makeInput({ type: "search", name: "q", placeholder: "Search" }, f); initialScan(0); expect(injectIcon).not.toHaveBeenCalled(); }); }); describe("T-S03–T-S06: icons get injected as aggression rises", () => { // T-S03: a standard login form at level 1 → icon injected (on the credential // fields). it("T-S03: standard login form + level 1 → icon injected", () => { createLoginForm(); initialScan(1); expect(injectIcon).toHaveBeenCalled(); }); // T-S04: an email-first form (email only) → icon injected on the email field. it("T-S04: email-first form + level 1 → icon injected", () => { createEmailFirstForm(); initialScan(1); expect(injectIcon).toHaveBeenCalled(); }); // T-S05: a bare password field (no username) at level 2 final-pass → icon // injected. This is the SPA case where the password appears after the // username step; without the final pass the field would never get an icon. it("T-S05: bare password + level 2 final-pass → icon injected", () => { makeInput({ type: "password", autocomplete: "current-password" }); initialScan(2, true); expect(injectIcon).toHaveBeenCalled(); }); // T-S07: multiple independent login forms → each qualifying password field // gets its own icon. it("T-S07: two login forms → an icon per form's password field", () => { createLoginForm(); createLoginForm(); initialScan(1); // At least two injections — one per password field. (Username fields may add // more; the floor is the two passwords.) expect(injectIcon.mock.calls.length).toBeGreaterThanOrEqual(2); }); }); describe("T-S08–T-S10: visibility, dynamic forms, idempotency", () => { // T-S08: a display:none form is not a fillable surface — no icon, even at // level 1, because isVisible() rejects its fields. it("T-S08: hidden (display:none) form → no icon", () => { const f = document.createElement("form"); f.style.display = "none"; document.body.appendChild(f); const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, f); const p = makeInput({ type: "password", autocomplete: "current-password" }, f); // Force isVisible() false by zeroing the rects (display:none isn't computed // for ancestors in jsdom, so neutralise the per-field rect shim too). for (const el of [u, p]) { Object.defineProperty(el, "getBoundingClientRect", { configurable: true, value: () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}) }), }); } initialScan(1); expect(injectIcon).not.toHaveBeenCalled(); }); // T-S09: a form inserted after the first scan gets an icon when the scan runs // again (the MutationObserver path re-invokes the reattach logic; here we // drive the rescan directly to keep the test deterministic — the debounce // timing itself is covered by the Playwright e2e). it("T-S09: a form inserted after the first scan is picked up on rescan", () => { initialScan(1); expect(injectIcon).not.toHaveBeenCalled(); // nothing on the page yet createLoginForm(); // dynamic insertion initialScan(1); expect(injectIcon).toHaveBeenCalled(); }); // T-S10: running the scan twice on the same form injects exactly once — // processedInputs makes it idempotent, preventing duplicate stacked icons. it("T-S10: scanning the same form twice → icon injected once per field", () => { createLoginForm(); initialScan(1); const afterFirst = injectIcon.mock.calls.length; initialScan(1); expect(injectIcon.mock.calls.length).toBe(afterFirst); // no new injections }); });