// @vitest-environment jsdom // // Test File 12 — interaction.ts target resolution (T-IT01–T-IT06). // // resolveAutofillInteractionTargets is the small, pure decision that turns a set // of detected fields + the clicked anchor into "what do we fill, and do we even // open?". It is deliberately narrow: it does NOT scope by form (the caller hands // it a pre-scoped field list) and it does NOT inspect the live DOM. The tests // below pin its real contract; where the brief assumed broader behavior // (form-scoping, email-first shape), the deviation is noted inline. import { beforeEach, describe, expect, it } from "vitest"; import type { DetectedField } from "./detect"; import { resolveAutofillInteractionTargets } from "./interaction"; import { makeInput } from "./_setup"; beforeEach(() => { document.body.innerHTML = ""; }); function field(type: DetectedField["type"], el: HTMLInputElement): DetectedField { return { type, el }; } describe("resolveAutofillInteractionTargets", () => { // T-IT01: a standard form resolves both the username and password elements and // opens. it("T-IT01: standard form → {usernameEl, passwordEl} and shouldOpen", () => { const u = makeInput({ type: "text" }); const p = makeInput({ type: "password" }); const targets = resolveAutofillInteractionTargets(p, [field("username", u), field("password", p)]); expect(targets.usernameEl).toBe(u); expect(targets.passwordEl).toBe(p); expect(targets.emailEl).toBeNull(); expect(targets.shouldOpen).toBe(true); }); // T-IT02: email-first step has no password. The function treats a lone email // as the identity field (usernameEl), so the caller fills entry.username into // it; emailEl stays null because there is no *separate* username field. (The // brief's {emailEl, passwordEl:null} shape predates this consolidation.) it("T-IT02: email-first form → email becomes usernameEl, passwordEl null", () => { const e = makeInput({ type: "email" }); const targets = resolveAutofillInteractionTargets(e, [field("email", e)]); expect(targets.usernameEl).toBe(e); expect(targets.passwordEl).toBeNull(); expect(targets.emailEl).toBeNull(); // shouldOpen depends on isAnyCredentialForm; an email field with no auth // context on a "/" path is not yet a credential form, so passive open is // gated. An explicit summon overrides (next test). }); // T-IT02b: distinct username AND email → both tracked separately. it("T-IT02b: distinct username + email → emailEl is tracked alongside usernameEl", () => { const u = makeInput({ type: "text", autocomplete: "username" }); const e = makeInput({ type: "email", autocomplete: "email" }); const p = makeInput({ type: "password" }); const targets = resolveAutofillInteractionTargets( u, [field("username", u), field("email", e), field("password", p)] ); expect(targets.usernameEl).toBe(u); expect(targets.emailEl).toBe(e); expect(targets.passwordEl).toBe(p); }); // T-IT03: password-only step → passwordEl set, usernameEl null, and it opens // (isPasswordStepForm). it("T-IT03: password-only form → passwordEl set, usernameEl null, shouldOpen", () => { const p = makeInput({ type: "password", autocomplete: "current-password" }); const targets = resolveAutofillInteractionTargets(p, [field("password", p)]); expect(targets.passwordEl).toBe(p); expect(targets.usernameEl).toBeNull(); expect(targets.shouldOpen).toBe(true); }); // T-IT04: no qualifying fields → do not open (passive). it("T-IT04: empty field set + passive → shouldOpen false", () => { const anchor = makeInput({ type: "text" }); const targets = resolveAutofillInteractionTargets(anchor, []); expect(targets.shouldOpen).toBe(false); expect(targets.usernameEl).toBeNull(); expect(targets.passwordEl).toBeNull(); }); // T-IT05: the function resolves targets from the field list it is GIVEN — form // scoping is the caller's job (index.ts calls detectLoginFields per the focused // field's context). With explicitSummon, a bare anchor still resolves as a // fallback target so an icon click always opens something. it("T-IT05: explicit summon on a bare password anchor falls back to the anchor", () => { const anchor = makeInput({ type: "password" }); const targets = resolveAutofillInteractionTargets(anchor, [], true); expect(targets.shouldOpen).toBe(true); expect(targets.passwordEl).toBe(anchor); }); it("T-IT05b: explicit summon on a bare username anchor falls back to the anchor", () => { const anchor = makeInput({ type: "text" }); const targets = resolveAutofillInteractionTargets(anchor, [], true); expect(targets.shouldOpen).toBe(true); expect(targets.usernameEl).toBe(anchor); }); // T-IT06: a detached anchor with no detected fields resolves to a non-opening // result rather than throwing — the graceful-abort path index.ts relies on. it("T-IT06: detached anchor + empty fields → shouldOpen false, no throw", () => { const detached = document.createElement("input"); detached.type = "text"; // never appended to the document let targets!: ReturnType; expect(() => { targets = resolveAutofillInteractionTargets(detached, []); }).not.toThrow(); expect(targets.shouldOpen).toBe(false); }); });