@gemini-wallet/core
Version:
Core SDK for Gemini Wallet integration with popup communication
472 lines (394 loc) • 14 kB
text/typescript
import { providerErrors } from "@metamask/rpc-errors";
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { Communicator } from "./communicator";
import { DEFAULT_CHAIN_ID } from "./constants";
import { AppMetadata, GeminiSdkEvent, GeminiSdkMessage, GeminiSdkMessageResponse } from "./types";
import { SDK_BACKEND_URL, SDK_VERSION } from "./utils";
// Set up global window mock before tests
(global as any).window = {
addEventListener: () => {},
location: {
origin: "http://localhost:3000",
},
removeEventListener: () => {},
};
// Mock window.open
const mockPopup = {
closed: false,
focus: mock(),
location: { href: SDK_BACKEND_URL },
postMessage: mock(),
};
// Mock utils
mock.module("./utils", () => ({
SDK_BACKEND_URL,
SDK_VERSION: "1.0.0",
closePopup: mock(),
openPopup: mock(() => mockPopup),
}));
describe("Communicator", () => {
let communicator: Communicator;
let appMetadata: AppMetadata;
let onDisconnectCallback: ReturnType<typeof mock>;
let messageListeners: Array<(event: MessageEvent) => void> = [];
// Helper to simulate message events
const simulateMessage = (data: any, origin: string = new URL(SDK_BACKEND_URL).origin) => {
const event = new MessageEvent("message", { data, origin });
messageListeners.forEach(listener => listener(event));
};
beforeEach(() => {
// Reset mocks
mock.restore();
messageListeners = [];
mockPopup.postMessage.mockClear();
mockPopup.focus.mockClear();
mockPopup.closed = false;
// Mock window event listeners
spyOn(window, "addEventListener").mockImplementation((event: string, listener: any) => {
if (event === "message") {
messageListeners.push(listener);
}
});
spyOn(window, "removeEventListener").mockImplementation((event: string, listener: any) => {
if (event === "message") {
const index = messageListeners.indexOf(listener);
if (index > -1) {
messageListeners.splice(index, 1);
}
}
});
appMetadata = {
icon: "https://test.com/icon.png",
name: "Test App",
};
onDisconnectCallback = mock();
communicator = new Communicator({
appMetadata,
onDisconnectCallback,
});
});
afterEach(() => {
messageListeners = [];
});
describe("constructor", () => {
it("should initialize with app metadata", () => {
expect(communicator).toBeDefined();
});
it("should store disconnect callback", () => {
const customCallback = mock();
const customCommunicator = new Communicator({
appMetadata,
onDisconnectCallback: customCallback,
});
expect(customCommunicator).toBeDefined();
});
});
describe("waitForPopupLoaded", () => {
it("should open popup and wait for load event", async () => {
const popupPromise = communicator.waitForPopupLoaded();
// Simulate popup loaded event
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "test-request-id",
});
const popup = await popupPromise;
expect(popup).toBe(mockPopup);
});
it("should send app context after popup loads", async () => {
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "test-request-id",
});
await popupPromise;
expect(mockPopup.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
chainId: DEFAULT_CHAIN_ID,
data: expect.objectContaining({
appMetadata,
origin: window.location.origin,
sdkVersion: SDK_VERSION,
}),
event: GeminiSdkEvent.POPUP_APP_CONTEXT,
}),
new URL(SDK_BACKEND_URL).origin,
);
});
it("should focus existing popup if already open", async () => {
// First open
const popupPromise1 = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise1;
// Second call should focus existing
mockPopup.focus.mockClear();
const popup2 = await communicator.waitForPopupLoaded();
expect(mockPopup.focus).toHaveBeenCalled();
expect(popup2).toBe(mockPopup);
});
it("should reopen popup if previous was closed", async () => {
// First open
const popupPromise1 = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise1;
// Mark as closed
mockPopup.closed = true;
// Should open new popup
const popupPromise2 = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req2",
});
const popup2 = await popupPromise2;
expect(popup2).toBe(mockPopup);
});
});
describe("postMessage", () => {
it("should post message to popup", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
const message: GeminiSdkMessage = {
chainId: 1,
data: {},
event: GeminiSdkEvent.SDK_CONNECT_REQUEST,
origin: window.location.origin,
requestId: "test-request",
};
await communicator.postMessage(message);
expect(mockPopup.postMessage).toHaveBeenCalledWith(message, new URL(SDK_BACKEND_URL).origin);
});
});
describe("postRequestAndWaitForResponse", () => {
it("should post request and wait for matching response", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
const request: GeminiSdkMessage = {
chainId: 1,
data: {},
event: GeminiSdkEvent.SDK_CONNECT_REQUEST,
origin: window.location.origin,
requestId: "test-request-123",
};
const responsePromise = communicator.postRequestAndWaitForResponse<GeminiSdkMessage, GeminiSdkMessageResponse>(
request,
);
// Simulate response
const response: GeminiSdkMessageResponse = {
chainId: 1,
data: { accounts: ["0x123"] },
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
origin: window.location.origin,
requestId: "test-request-123",
};
simulateMessage(response);
const result = await responsePromise;
expect(result).toEqual(response);
});
it("should ignore responses with different requestId", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
const request: GeminiSdkMessage = {
chainId: 1,
data: {},
event: GeminiSdkEvent.SDK_CONNECT_REQUEST,
origin: window.location.origin,
requestId: "correct-id",
};
const responsePromise = communicator.postRequestAndWaitForResponse<GeminiSdkMessage, GeminiSdkMessageResponse>(
request,
);
// Send wrong response
simulateMessage({
data: { accounts: [] },
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
requestId: "wrong-id",
});
// Send correct response
simulateMessage({
data: { accounts: ["0x456"] },
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
requestId: "correct-id",
});
const result = await responsePromise;
expect(result.requestId).toBe("correct-id");
});
});
describe("onMessage", () => {
it("should filter messages by predicate", async () => {
const messagePromise = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === GeminiSdkEvent.SDK_CONNECT_RESPONSE,
);
// Send non-matching message
simulateMessage({
event: GeminiSdkEvent.SDK_DISCONNECT,
requestId: "req1",
});
// Send matching message
simulateMessage({
data: { success: true },
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
requestId: "req2",
});
const result = await messagePromise;
expect(result.event).toBe(GeminiSdkEvent.SDK_CONNECT_RESPONSE);
expect(result.requestId).toBe("req2");
});
it("should ignore messages from wrong origin", async () => {
const messagePromise = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === GeminiSdkEvent.SDK_CONNECT_RESPONSE,
);
// Send from wrong origin
simulateMessage(
{
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
requestId: "wrong-origin",
},
"https://evil.com",
);
// Send from correct origin
simulateMessage({
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
requestId: "correct-origin",
});
const result = await messagePromise;
expect(result.requestId).toBe("correct-origin");
});
it("should remove listener after message received", async () => {
const initialListenerCount = messageListeners.length;
const messagePromise = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === GeminiSdkEvent.SDK_CONNECT_RESPONSE,
);
// Should have added a listener
expect(messageListeners.length).toBe(initialListenerCount + 1);
simulateMessage({
event: GeminiSdkEvent.SDK_CONNECT_RESPONSE,
requestId: "test",
});
await messagePromise;
// Should have removed the listener
expect(messageListeners.length).toBe(initialListenerCount);
});
});
describe("popup disconnect handling", () => {
it("should call disconnect callback on SDK_DISCONNECT event", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
// Simulate disconnect event
simulateMessage({
event: GeminiSdkEvent.SDK_DISCONNECT,
requestId: "disconnect-req",
});
// Wait for event processing
await new Promise(resolve => setTimeout(resolve, 10));
expect(onDisconnectCallback).toHaveBeenCalled();
});
it("should reject pending requests on POPUP_UNLOADED", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
// Start a pending request
const pendingPromise = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === ("WILL_NEVER_ARRIVE" as any),
);
// Simulate popup unloaded
simulateMessage({
event: GeminiSdkEvent.POPUP_UNLOADED,
requestId: "unload-req",
});
try {
await pendingPromise;
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(providerErrors.userRejectedRequest().code);
}
});
it("should clear all listeners on disconnect", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
// Add multiple listeners - create promises but don't await yet
const promise1 = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === ("EVENT1" as any),
);
const promise2 = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === ("EVENT2" as any),
);
// Collect rejection errors
const errors: any[] = [];
promise1.catch(err => errors.push(err));
promise2.catch(err => errors.push(err));
const initialListenerCount = messageListeners.length;
expect(initialListenerCount).toBeGreaterThan(0);
// Simulate disconnect
simulateMessage({
event: GeminiSdkEvent.SDK_DISCONNECT,
requestId: "disconnect",
});
// Wait for cleanup and error propagation
await new Promise(resolve => setTimeout(resolve, 50));
// Both promises should have rejected with user rejection error
expect(errors.length).toBe(2);
expect(errors[0].code).toBe(providerErrors.userRejectedRequest().code);
expect(errors[1].code).toBe(providerErrors.userRejectedRequest().code);
});
});
describe("error handling", () => {
it("should handle popup unloaded event", async () => {
// Open popup first
const popupPromise = communicator.waitForPopupLoaded();
simulateMessage({
event: GeminiSdkEvent.POPUP_LOADED,
requestId: "req1",
});
await popupPromise;
// Start a request that will be rejected
const pendingPromise = communicator.onMessage<GeminiSdkMessage, GeminiSdkMessageResponse>(
message => message.event === ("WILL_NEVER_ARRIVE" as any),
);
// Force rejection by simulating unload
simulateMessage({
event: GeminiSdkEvent.POPUP_UNLOADED,
requestId: "unload",
});
try {
await pendingPromise;
expect(true).toBe(false); // Should not reach here
} catch (error: any) {
expect(error.code).toBe(providerErrors.userRejectedRequest().code);
}
});
});
});