import { beforeEach, describe, expect, it, vi } from "vitest"; // Fresh shim install per test — the module installs `globalThis.chrome` as a // side effect at import time, and internal listener/queue state lives in // module-level closures, so each test needs its own module instance. async function freshShim() { vi.resetModules(); delete (globalThis as Record).Capacitor; delete (globalThis as Record).chrome; await import("./chrome-shim"); return (globalThis as unknown as { chrome: typeof chrome }).chrome; } describe("chrome-shim runtime.sendMessage dispatch", () => { beforeEach(() => { vi.resetModules(); }); it("queues sendMessage calls until a listener registers, per messages.ts's own comment", async () => { const chromeShim = await freshShim(); const sent = chromeShim.runtime.sendMessage({ type: "GET_VAULT_STATUS" }); let resolved = false; void sent.then(() => { resolved = true; }); await Promise.resolve(); expect(resolved).toBe(false); chromeShim.runtime.onMessage.addListener((_msg, _sender, sendResponse) => { sendResponse({ ok: true, unlocked: false }); return true; }); const result = await sent; expect(result).toEqual({ ok: true, unlocked: false }); }); it("invokes handleMessage exactly once even though messages.ts registers via both onMessage.addListener and __peachRegisterMessageHandler", async () => { const chromeShim = await freshShim(); let handleMessageCalls = 0; // Mirrors setupMessageHandler()'s exact shape: register a real listener, // then also call the (now-inert) mobile hook a second time. chromeShim.runtime.onMessage.addListener((msg, _sender, sendResponse) => { handleMessageCalls++; Promise.resolve({ ok: true, echoed: msg }).then(sendResponse); return true; }); (globalThis as unknown as { __peachRegisterMessageHandler: (fn: unknown) => void }) .__peachRegisterMessageHandler(async (m: unknown) => { handleMessageCalls++; return { ok: true, echoed: m }; }); const result = await chromeShim.runtime.sendMessage({ type: "GET_VAULT_STATUS" }); expect(result).toEqual({ ok: true, echoed: { type: "GET_VAULT_STATUS" } }); expect(handleMessageCalls).toBe(1); }); it("delivers every message to broadcast-only listeners (App.tsx's VAULT_LOCKED pattern) without them winning the response", async () => { const chromeShim = await freshShim(); const seenByBroadcast: unknown[] = []; chromeShim.runtime.onMessage.addListener((_msg, _sender, sendResponse) => { Promise.resolve({ ok: true, from: "primary" }).then(sendResponse); return true; }); chromeShim.runtime.onMessage.addListener((msg) => { seenByBroadcast.push(msg); // Never calls sendResponse and never returns true — matches App.tsx's // handleRuntimeMessage exactly. }); const result = await chromeShim.runtime.sendMessage({ type: "VAULT_LOCKED" }); expect(result).toEqual({ ok: true, from: "primary" }); expect(seenByBroadcast).toEqual([{ type: "VAULT_LOCKED" }]); }); it("resolves undefined immediately when no listener intends to respond (matches Chrome's own semantics)", async () => { const chromeShim = await freshShim(); chromeShim.runtime.onMessage.addListener(() => { // No sendResponse call, no `return true`. }); const result = await chromeShim.runtime.sendMessage({ type: "UNKNOWN" }); expect(result).toBeUndefined(); }); }); describe("chrome-shim storage", () => { it("storage.session round-trips in-memory and fires onChanged", async () => { const chromeShim = await freshShim(); const changes: unknown[] = []; chromeShim.storage.onChanged.addListener((c, area) => changes.push({ c, area })); await chromeShim.storage.session.set({ foo: "bar" }); const got = await chromeShim.storage.session.get("foo"); expect(got).toEqual({ foo: "bar" }); expect(changes).toEqual([{ c: { foo: { oldValue: undefined, newValue: "bar" } }, area: "session" }]); }); it("storage.local falls back to an in-memory store when PeachNative is unavailable", async () => { const chromeShim = await freshShim(); await chromeShim.storage.local.set({ peach_settings: { autoLockMinutes: 5 } }); const got = await chromeShim.storage.local.get("peach_settings"); expect(got).toEqual({ peach_settings: { autoLockMinutes: 5 } }); }); it("storage.local routes through PeachNative.kv* when available, JSON-encoding at the boundary", async () => { const chromeShim = await freshShim(); const kv = new Map(); // freshShim() clears globalThis.Capacitor before importing the shim (so // module import order is deterministic across tests) — set it after // import, which is fine since nativePlugin() is read fresh on every call. (globalThis as unknown as { Capacitor: unknown }).Capacitor = { isNativePlatform: () => true, Plugins: { PeachNative: { async kvGet(key: string) { return kv.has(key) ? kv.get(key)! : null; }, async kvSet(key: string, value: string) { kv.set(key, value); }, async kvKeys() { return Array.from(kv.keys()); }, }, }, }; await chromeShim.storage.local.set({ peach_vault_a: { ci: "abc", nonce: "def" } }); expect(kv.get("peach_vault_a")).toBe(JSON.stringify({ ci: "abc", nonce: "def" })); const got = await chromeShim.storage.local.get("peach_vault_a"); expect(got).toEqual({ peach_vault_a: { ci: "abc", nonce: "def" } }); }); });