// @vitest-environment jsdom // // Test File 6 — inject.ts injectFieldIcon (T-IC01–T-IC08). // // injectFieldIcon renders the small peach button into the LIGHT DOM (appended to // document.body, position:fixed), so unlike the selector it is fully observable // from a unit test. We import the REAL inject.ts and assert on the button it // produces: presence, accessibility, click wiring, teardown, and the // too-narrow-field hide rule. Where the brief assumed behavior that lives // elsewhere (idempotency, autocomplete policy), it is documented, not faked. import "./_setup"; // chrome mock + ResizeObserver polyfill (observeAnchorPosition needs it) import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { injectFieldIcon } from "./inject"; import { makeInput, makeFieldVisible } from "./_setup"; const ICON_SELECTOR = 'button[aria-label="Peach autofill"]'; beforeEach(() => { document.body.innerHTML = ""; }); afterEach(() => { document.body.innerHTML = ""; }); describe("T-IC01, T-IC08: rendering and accessibility", () => { // T-IC01: the icon button is injected into the document for a normal field. it("T-IC01: injects a position:fixed button into the document", () => { const field = makeInput({ type: "password" }); const cleanup = injectFieldIcon(field, () => {}); const btn = document.querySelector(ICON_SELECTOR); expect(btn).not.toBeNull(); expect(btn!.style.position).toBe("fixed"); cleanup(); }); // T-IC08: the button carries an aria-label and title for screen readers. it("T-IC08: button has an aria-label and title", () => { const field = makeInput({ type: "password" }); const cleanup = injectFieldIcon(field, () => {}); const btn = document.querySelector(ICON_SELECTOR)!; expect(btn.getAttribute("aria-label")).toBe("Peach autofill"); expect(btn.title).toBeTruthy(); cleanup(); }); }); describe("T-IC02: click opens the selector", () => { // T-IC02: clicking the icon invokes the onClick callback (which, in index.ts, // is what summons the selector). it("T-IC02: clicking the icon calls onClick exactly once", () => { const field = makeInput({ type: "password" }); const onClick = vi.fn(); const cleanup = injectFieldIcon(field, onClick); const btn = document.querySelector(ICON_SELECTOR)!; btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onClick).toHaveBeenCalledTimes(1); cleanup(); }); }); describe("T-IC04: teardown", () => { // T-IC04: the returned cleanup removes the button — index.ts calls it when the // field leaves the DOM, so a stale icon never lingers. it("T-IC04: cleanup() removes the injected button", () => { const field = makeInput({ type: "password" }); const cleanup = injectFieldIcon(field, () => {}); expect(document.querySelector(ICON_SELECTOR)).not.toBeNull(); cleanup(); expect(document.querySelector(ICON_SELECTOR)).toBeNull(); }); }); describe("T-IC06: size / narrow-field hide rule", () => { // T-IC06: on a field too narrow to host the icon cleanly (<40px wide), the // button is hidden (opacity 0, pointer-events none) so it never overlaps a // tiny input or an adjacent control. it("T-IC06: a <40px-wide field hides the icon", () => { const field = makeInput({ type: "password" }); Object.defineProperty(field, "getBoundingClientRect", { configurable: true, value: () => ({ width: 30, height: 24, top: 10, left: 10, right: 40, bottom: 34, x: 10, y: 10, toJSON: () => ({}) }), }); const cleanup = injectFieldIcon(field, () => {}); const btn = document.querySelector(ICON_SELECTOR)!; // Read synchronously, before the post-mount requestAnimationFrame fires. expect(btn.style.opacity).toBe("0"); expect(btn.style.pointerEvents).toBe("none"); cleanup(); }); it("T-IC06b: a normally-wide field shows the icon", () => { const field = makeInput({ type: "password" }); makeFieldVisible(field); // 160×32 → wide enough const cleanup = injectFieldIcon(field, () => {}); const btn = document.querySelector(ICON_SELECTOR)!; expect(btn.style.opacity).toBe("1"); cleanup(); }); }); describe("T-IC03: renderer idempotency boundary", () => { // T-IC03: injectFieldIcon is NOT idempotent on its own — each call appends a // button. Idempotency is enforced by the caller (index.ts processedInputs), // covered by content.initial-scan.test.ts T-S10. We pin the real behavior so // the contract is explicit. it("T-IC03: injectFieldIcon itself is not idempotent (dedup is the caller's job)", () => { const field = makeInput({ type: "password" }); const c1 = injectFieldIcon(field, () => {}); const c2 = injectFieldIcon(field, () => {}); expect(document.querySelectorAll(ICON_SELECTOR).length).toBe(2); c1(); c2(); }); });