123456789101112131415import { vi } from "vitest";1617const { predictBatch, dismissSpy } = vi.hoisted(() => ({18 predictBatch: vi.fn(() => Promise.resolve([] as unknown[])),19 dismissSpy: vi.fn(),20}));2122vi.mock("./inject", () => ({23 injectFieldIcon: vi.fn(() => vi.fn()),24 showAutofillSelector: vi.fn(() => dismissSpy),25 showTOTPSelector: vi.fn(() => dismissSpy),26 showCardSelector: vi.fn(() => dismissSpy),27 showRememberSiteToast: vi.fn(),28 showPasskeyAvailable: vi.fn(),29 showPasskeyProGate: vi.fn(),30 showPasskeyRegistrationOffer: vi.fn(),31}));32vi.mock("./content-ml-detector", () => ({33 contentMLDetector: { predictBatch },34}));3536import { afterEach, beforeEach, describe, expect, it } from "vitest";37import "./_setup";38import { resetChromeMock, setMessageResponder, sentMessages, messageListeners, createLoginForm, makeInput } from "./_setup";39import "./index"; 40import * as inject from "./inject";41import { notifyPasskeyToastClosed } from "./passkey-toast/lifecycle";4243const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;44const showCardSelector = inject.showCardSelector as unknown as ReturnType<typeof vi.fn>;45const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType<typeof vi.fn>;46const SELF_SENDER = { id: "peach-test-extension-id" };47const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];48495051function dispatch(msg: any, sender: any = SELF_SENDER) {52 const sendResponse = vi.fn();53 for (const l of messageListeners) l(msg, sender, sendResponse);54 return sendResponse;55}5657function withEntries(): void {58 setMessageResponder((msg) =>59 msg.type === "AUTOFILL_QUERY" ? { ok: true, data: ENTRIES } : { ok: true, data: null }60 );61}6263let urlCounter = 0;64beforeEach(async () => {65 try { history.pushState({}, "", `/m${++urlCounter}`); } catch { }66 document.body.innerHTML = "";67 await new Promise((r) => setTimeout(r, 0));68 resetChromeMock();69 showSelector.mockClear();70 showCardSelector.mockClear();71 showPasskeyProGate.mockClear();72 predictBatch.mockReset();73 predictBatch.mockReturnValue(Promise.resolve([]));74});7576afterEach(async () => {77 await new Promise((r) => setTimeout(r, 0));78});7980describe("T-M01–T-M02: SUMMON_AUTOFILL", () => {81 82 it("T-M01: SUMMON_AUTOFILL with a focused login field → selector opens", async () => {83 const { password } = createLoginForm();84 password.focus();85 withEntries();86 const send = dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });87 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());88 expect(send).toHaveBeenCalled(); 89 });9091 92 93 it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => {94 expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow();95 expect(showSelector).not.toHaveBeenCalled();96 });9798 it("PB-26-201: no-form command carries capability from inventory to selected fill", async () => {99 const originalAttachShadow = Element.prototype.attachShadow;100 const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {101 return originalAttachShadow.call(this, { mode: "open" });102 });103 setMessageResponder((msg) => {104 if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: ENTRIES };105 if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { ...ENTRIES[0], password: "secret" } };106 return { ok: true, data: null };107 });108109 try {110 dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "no-form-capability" });111 await vi.waitFor(() => {112 expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({113 payload: { includeAll: true, capability: "no-form-capability" },114 });115 });116 const findRow = () => Array.from(document.body.children)117 .map((host) => host.shadowRoot?.querySelector<HTMLElement>(".e"))118 .find((candidate): candidate is HTMLElement => candidate !== null && candidate !== undefined);119 await vi.waitFor(() => expect(findRow()).toBeDefined());120 const row = findRow();121 row?.click();122 await vi.waitFor(() => {123 expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({124 payload: { id: "e1", capability: "no-form-capability" },125 });126 });127 } finally {128 attachSpy.mockRestore();129 }130 });131132 it("PB-26-201: card command carries capability into inventory and selector actions", async () => {133 const form = document.createElement("form");134 document.body.appendChild(form);135 const number = makeInput({ type: "text", autocomplete: "cc-number" }, form);136 makeInput({ type: "text", autocomplete: "cc-exp" }, form);137 makeInput({ type: "text", autocomplete: "cc-csc" }, form);138 number.focus();139 setMessageResponder((msg) =>140 msg.type === "AUTOFILL_QUERY"141 ? { ok: true, data: [{ id: "card-1", title: "Personal Visa", last4: "1111" }] }142 : { ok: true, data: null },143 );144145 dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "card-capability" });146147 await vi.waitFor(() => expect(showCardSelector).toHaveBeenCalled());148 expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY" && msg.payload?.kind === "card")).toMatchObject({149 payload: { capability: "card-capability" },150 });151 expect(showCardSelector.mock.calls.at(-1)![2]).toMatchObject({ capability: "card-capability" });152 });153});154155describe("T-M03–T-M05: VAULT_UNLOCKED", () => {156 157 it("T-M03: VAULT_UNLOCKED with a pending anchor → re-triggers and reopens", async () => {158 const { password } = createLoginForm();159 password.focus();160 withEntries();161 dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" }); 162 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());163 showSelector.mockClear();164165 dispatch({ type: "VAULT_UNLOCKED" });166 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled()); 167 });168169 170 171 it("T-M05: VAULT_UNLOCKED with no pending interaction → no-op, no crash", async () => {172 (document.activeElement as HTMLElement | null)?.blur?.();173 const send = dispatch({ type: "VAULT_UNLOCKED" });174 await new Promise((r) => setTimeout(r, 20));175 expect(send).toHaveBeenCalledWith({ ok: true });176 expect(showSelector).not.toHaveBeenCalled();177 });178179 180 181 182 183 184 it("T-M04: VAULT_UNLOCKED while passkeySummonActive → no re-trigger", async () => {185 186 187 188 const anchor = makeInput({ type: "text", name: "search-box", placeholder: "Search" });189 anchor.focus();190 dispatch({ type: "SUMMON_AUTOFILL", source: "passkey-unlock" }); 191 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());192 showSelector.mockClear();193194 dispatch({ type: "VAULT_UNLOCKED" }); 195 await new Promise((r) => setTimeout(r, 20));196 expect(showSelector).not.toHaveBeenCalled();197 });198});199200describe("PB-26-319: toast close signals require the extension token", () => {201 it("ignores a page-forged toast-closed postMessage", async () => {202 const { password } = createLoginForm();203 password.focus();204 withEntries();205 dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "extension-token" } });206 window.dispatchEvent(new MessageEvent("message", { source: window, data: { source: "peach-passkey-toast", type: "toast-closed", token: "page-token" } }));207 dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });208 await new Promise((resolve) => setTimeout(resolve, 0));209 expect(showSelector).not.toHaveBeenCalled();210 });211212 it("accepts the isolated-world toast lifecycle token", async () => {213 const { password } = createLoginForm();214 password.focus();215 withEntries();216 dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "extension-token" } });217 notifyPasskeyToastClosed("extension-token");218 await new Promise((resolve) => setTimeout(resolve, 0));219 dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });220 await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());221 });222223 it("ignores a stale isolated-world close token after a replacement toast appears", async () => {224 const { password } = createLoginForm();225 password.focus();226 withEntries();227 dispatch({ type: "SHOW_PASSKEY_TOAST", payload: { toastToken: "replacement-token" } });228 notifyPasskeyToastClosed("previous-token");229 dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" });230 await new Promise((resolve) => setTimeout(resolve, 0));231 expect(showSelector).not.toHaveBeenCalled();232 });233});234235describe("T-M08–T-M10: PING, passkey gate, robustness", () => {236 237 238 it("PING → sendResponse({ ok: true })", () => {239 const send = dispatch({ type: "PING" });240 expect(send).toHaveBeenCalledWith({ ok: true });241 });242243 244 it("GET_SITE_PASSWORD_POLICY → returns a policy object", () => {245 document.body.innerHTML = '<input type="password" maxlength="32" />';246 const send = dispatch({ type: "GET_SITE_PASSWORD_POLICY" });247 expect(send).toHaveBeenCalledTimes(1);248 const arg = send.mock.calls[0]![0];249 expect(arg.ok).toBe(true);250 expect(arg.data.maxLength).toBe(32);251 });252253 254 it("PASSKEY_PRO_GATE → showPasskeyProGate called", () => {255 dispatch({ type: "PASSKEY_PRO_GATE" });256 expect(showPasskeyProGate).toHaveBeenCalled();257 });258259 260 261 it("T-M09: unknown message type → handled gracefully", () => {262 expect(() => dispatch({ type: "TOTALLY_UNKNOWN_MESSAGE" })).not.toThrow();263 expect(showSelector).not.toHaveBeenCalled();264 });265266 267 268 it("T-M10: message from a foreign sender id is ignored", () => {269 const send = dispatch({ type: "PING" }, { id: "some-other-extension" });270 expect(send).not.toHaveBeenCalled();271 });272});