// @vitest-environment jsdom // // Test File 4 — index.ts message routing (T-M01–T-M13). // // index.ts registers a single chrome.runtime.onMessage listener that routes // SUMMON_AUTOFILL (keyboard shortcut / toolbar button), VAULT_UNLOCKED (retry // after unlock), PING, GET_SITE_PASSWORD_POLICY, and the passkey-toast control // messages. The harness captures every registered listener; we invoke them with // a spy sendResponse and a same-extension sender, then assert the routed effect. // // Where the brief assumed messages that this listener does not own // (DISMISS_AUTOFILL, FILL_AND_SUBMIT), the cases are documented rather than // faked. import { vi } from "vitest"; const { predictBatch, dismissSpy } = vi.hoisted(() => ({ predictBatch: vi.fn(() => Promise.resolve([] as unknown[])), dismissSpy: vi.fn(), })); vi.mock("./inject", () => ({ injectFieldIcon: vi.fn(() => vi.fn()), showAutofillSelector: vi.fn(() => dismissSpy), showTOTPSelector: vi.fn(() => dismissSpy), showCardSelector: vi.fn(() => dismissSpy), showRememberSiteToast: vi.fn(), showPasskeyAvailable: vi.fn(), showPasskeyProGate: vi.fn(), showPasskeyRegistrationOffer: vi.fn(), })); vi.mock("./content-ml-detector", () => ({ contentMLDetector: { predictBatch }, })); import { afterEach, beforeEach, describe, expect, it } from "vitest"; import "./_setup"; import { resetChromeMock, setMessageResponder, sentMessages, messageListeners, createLoginForm, makeInput } from "./_setup"; import "./index"; // registers the onMessage listener at import time import * as inject from "./inject"; const showSelector = inject.showAutofillSelector as unknown as ReturnType; const showCardSelector = inject.showCardSelector as unknown as ReturnType; const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType; const SELF_SENDER = { id: "peach-test-extension-id" }; const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }]; // Deliver a message to every registered onMessage listener; return the // sendResponse spy so the test can assert the reply. function dispatch(msg: any, sender: any = SELF_SENDER) { const sendResponse = vi.fn(); for (const l of messageListeners) l(msg, sender, sendResponse); return sendResponse; } function withEntries(): void { setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null } ); } let urlCounter = 0; beforeEach(async () => { try { history.pushState({}, "", `/m${++urlCounter}`); } catch { /* ignore */ } document.body.innerHTML = ""; await new Promise((r) => setTimeout(r, 0)); resetChromeMock(); showSelector.mockClear(); showCardSelector.mockClear(); showPasskeyProGate.mockClear(); predictBatch.mockReset(); predictBatch.mockReturnValue(Promise.resolve([])); }); afterEach(async () => { await new Promise((r) => setTimeout(r, 0)); }); describe("T-M01–T-M02: SUMMON_AUTOFILL", () => { // T-M01: the shortcut on a login form opens the selector for the focused field. it("T-M01: SUMMON_AUTOFILL with a focused login field → selector opens", async () => { const { password } = createLoginForm(); password.focus(); withEntries(); const send = dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); expect(send).toHaveBeenCalled(); // listener acknowledged }); // T-M02: the shortcut on a page with no login form must not crash and must not // open the credential selector (it falls back to the search palette). it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => { expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow(); expect(showSelector).not.toHaveBeenCalled(); }); it("PB-26-201: no-form command carries capability from inventory to selected fill", async () => { const originalAttachShadow = Element.prototype.attachShadow; const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) { return originalAttachShadow.call(this, { mode: "open" }); }); setMessageResponder((msg) => { if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: ENTRIES }; if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { ...ENTRIES[0], password: "secret" } }; return { ok: true, data: null }; }); try { dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "no-form-capability" }); await vi.waitFor(() => { expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({ payload: { includeAll: true, capability: "no-form-capability" }, }); }); const findRow = () => Array.from(document.body.children) .map((host) => host.shadowRoot?.querySelector(".e")) .find((candidate): candidate is HTMLElement => candidate !== null && candidate !== undefined); await vi.waitFor(() => expect(findRow()).toBeDefined()); const row = findRow(); row?.click(); await vi.waitFor(() => { expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({ payload: { id: "e1", capability: "no-form-capability" }, }); }); } finally { attachSpy.mockRestore(); } }); it("PB-26-201: card command carries capability into inventory and selector actions", async () => { const form = document.createElement("form"); document.body.appendChild(form); const number = makeInput({ type: "text", autocomplete: "cc-number" }, form); makeInput({ type: "text", autocomplete: "cc-exp" }, form); makeInput({ type: "text", autocomplete: "cc-csc" }, form); number.focus(); setMessageResponder((msg) => msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [{ id: "card-1", title: "Personal Visa", last4: "1111" }] } : { ok: true, data: null }, ); dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "card-capability" }); await vi.waitFor(() => expect(showCardSelector).toHaveBeenCalled()); expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY" && msg.payload?.kind === "card")).toMatchObject({ payload: { capability: "card-capability" }, }); expect(showCardSelector.mock.calls.at(-1)![2]).toMatchObject({ capability: "card-capability" }); }); }); describe("T-M03–T-M05: VAULT_UNLOCKED", () => { // T-M03: after unlock, the pending field's interaction is retried. it("T-M03: VAULT_UNLOCKED with a pending anchor → re-triggers and reopens", async () => { const { password } = createLoginForm(); password.focus(); withEntries(); dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); // opens → sets currentAnchorField await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); showSelector.mockClear(); dispatch({ type: "VAULT_UNLOCKED" }); await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); // reopened at the same field }); // T-M05: VAULT_UNLOCKED with nothing pending and no focused field is a no-op — // it must not throw or open anything. it("T-M05: VAULT_UNLOCKED with no pending interaction → no-op, no crash", async () => { (document.activeElement as HTMLElement | null)?.blur?.(); const send = dispatch({ type: "VAULT_UNLOCKED" }); await new Promise((r) => setTimeout(r, 20)); expect(send).toHaveBeenCalledWith({ ok: true }); expect(showSelector).not.toHaveBeenCalled(); }); // T-M04: when a passkey unlock is in flight (passkeySummonActive), VAULT_UNLOCKED // must NOT re-trigger autofill — the passkey toast owns the next step. We arm // passkeySummonActive via the passkey-unlock summon path, then assert the // unlock only dismisses (no fresh credential selector). Ordered last in the // group because the VAULT_UNLOCKED it sends also clears the flag. it("T-M04: VAULT_UNLOCKED while passkeySummonActive → no re-trigger", async () => { // The passkey-unlock branch only fires when there is NO credential form on // the page (vault locked mid-passkey-GET); it anchors the locked prompt on // whatever element is focused (typically the sign-in button). const anchor = makeInput({ type: "text", name: "search-box", placeholder: "Search" }); anchor.focus(); dispatch({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }); // sets passkeySummonActive await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); showSelector.mockClear(); dispatch({ type: "VAULT_UNLOCKED" }); // passkey branch: dismiss only, no reopen await new Promise((r) => setTimeout(r, 20)); expect(showSelector).not.toHaveBeenCalled(); }); }); describe("T-M08–T-M10: PING, passkey gate, robustness", () => { // PING is the liveness probe the background uses to confirm the content script // is injected. it("PING → sendResponse({ ok: true })", () => { const send = dispatch({ type: "PING" }); expect(send).toHaveBeenCalledWith({ ok: true }); }); // GET_SITE_PASSWORD_POLICY returns the scraped policy for the generator. it("GET_SITE_PASSWORD_POLICY → returns a policy object", () => { document.body.innerHTML = ''; const send = dispatch({ type: "GET_SITE_PASSWORD_POLICY" }); expect(send).toHaveBeenCalledTimes(1); const arg = send.mock.calls[0]![0]; expect(arg.ok).toBe(true); expect(arg.data.maxLength).toBe(32); }); // PASSKEY_PRO_GATE surfaces the upgrade prompt (State 11). it("PASSKEY_PRO_GATE → showPasskeyProGate called", () => { dispatch({ type: "PASSKEY_PRO_GATE" }); expect(showPasskeyProGate).toHaveBeenCalled(); }); // T-M09: an unknown message type falls through harmlessly — no throw, no // unhandled rejection. it("T-M09: unknown message type → handled gracefully", () => { expect(() => dispatch({ type: "TOTALLY_UNKNOWN_MESSAGE" })).not.toThrow(); expect(showSelector).not.toHaveBeenCalled(); }); // T-M10: a foreign-extension sender (sender.id mismatch) is ignored entirely — // a security boundary, not just robustness. it("T-M10: message from a foreign sender id is ignored", () => { const send = dispatch({ type: "PING" }, { id: "some-other-extension" }); expect(send).not.toHaveBeenCalled(); }); });