Skip to content

Commit

Permalink
chore: tests for transport-chrome, transport-dom
Browse files Browse the repository at this point in the history
* mock chrome messaging system in private mock-chrome package
* implement tests for transport-chrome (#2067)
* improve tests for transport-dom (#2078)
  • Loading branch information
turbocrime authored Feb 26, 2025
1 parent e97542a commit ca71c02
Show file tree
Hide file tree
Showing 20 changed files with 3,296 additions and 562 deletions.
6 changes: 6 additions & 0 deletions .changeset/late-deers-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@penumbra-zone/transport-chrome': patch
'@penumbra-zone/transport-dom': patch
---

added tests (no external change)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@eslint/js": "^9.6.0",
"@microsoft/api-extractor": "^7.47.0",
"@penumbra-zone/configs": "workspace:*",
"@repo/mock-chrome": "workspace:*",
"@repo/tailwind-config": "workspace:*",
"@storybook/react-vite": "^8.4.2",
"@testing-library/jest-dom": "^6.4.5",
Expand Down
23 changes: 23 additions & 0 deletions packages/mock-chrome/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@repo/mock-chrome",
"version": "0.0.1",
"private": true,
"license": "(MIT OR Apache-2.0)",
"type": "module",
"engine": {
"node": ">=22"
},
"scripts": {
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint:strict": "tsc --noEmit && eslint src --max-warnings 0",
"test": "vitest run"
},
"exports": {
"./*": "./src/*.api..ts",
"./runtime/*": "./src/runtime/*.api.ts"
},
"devDependencies": {
"@types/chrome": "^0.0.268"
}
}
21 changes: 21 additions & 0 deletions packages/mock-chrome/src/chrome.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Renamed, rewritten, and some slightly narrowed types to make them easier to work with

declare interface ChromeEvent<
T extends (...args: P) => void = (...args: unknown) => void,
P extends unknown[] = Parameters<T>,
> {
addListener: (callback: T) => void;
hasListener: (callback: T) => boolean;
hasListeners: () => boolean;
removeListener: (callback: T) => void;
}

declare type ChromeEventListener<E> = E extends ChromeEvent<infer T> ? T : never;
declare type ChromeSender = chrome.runtime.MessageSender;
declare type ChromeConnectInfo = chrome.runtime.ConnectInfo;
declare type ChromePort = chrome.runtime.Port;

/** `chrome.runtime.connect` but narrower, to exclude the inter-extension case */
declare type ChromeConnect = (info?: ChromeConnectInfo) => ChromePort;

declare type ChromeExtensionConnectEvent = chrome.runtime.ExtensionConnectEvent;
54 changes: 54 additions & 0 deletions packages/mock-chrome/src/event.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { vi, MockedFunction, Mocked } from 'vitest';

export const eventMocked = <E extends ChromeEvent>(event: E): MockedChromeEvent<E> =>
event as unknown as MockedChromeEvent<E>;

/**
* Mock the listeners manager for any `chrome.events.Event` type, such as
* `chrome.runtime.ExtensionConnectEvent` or `chrome.runtime.PortMessageEvent`.
*
* Does not support event rules or listeners with `filter` parameters.
*
* @param listeners set object, will be used for internal state
* @returns event manager with mocked features, direct dispatch, and insight to listeners
*/

export const mockEvent = <
E extends ChromeEvent,
L extends E extends ChromeEvent<infer T> ? T : never = E extends ChromeEvent<infer T> ? T : never,
>(
listeners = new Set<L>(),
): MockedChromeEvent<E> => {
// dispatch method to activate the listeners
const dispatch = (...i: Parameters<L>) => listeners.forEach(listener => listener(...i));

const addListener = (i: L): void => void listeners.add(i);
const hasListener = (i: L): boolean => listeners.has(i);
const hasListeners = (): boolean => listeners.size > 0;
const removeListener = (i: L): void => void listeners.delete(i);

return {
listeners,
dispatch: vi.fn(dispatch),

addListener: vi.fn(addListener),
hasListener: vi.fn(hasListener),
hasListeners: vi.fn(hasListeners),
removeListener: vi.fn(removeListener),
} as unknown as MockedChromeEvent<E>;
};

export interface MockedChromeEvent<T extends ChromeEvent = ChromeEvent>
extends Mocked<ChromeEvent<ChromeEventListener<T>>> {
listeners: Set<ChromeEventListener<T>>;

dispatch: MockedFunction<(...args: Parameters<ChromeEventListener<T>>) => void>;

addListener: MockedFunction<(callback: ChromeEventListener<T>) => void>;

hasListener: MockedFunction<(callback: ChromeEventListener<T>) => boolean>;

hasListeners: MockedFunction<() => boolean>;

removeListener: MockedFunction<(callback: ChromeEventListener<T>) => void>;
}
42 changes: 42 additions & 0 deletions packages/mock-chrome/src/event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import { mockEvent } from './event.mock.js';

describe('mockEvent', () => {
it('should manage listeners', () => {
const event = mockEvent();

const listener = vi.fn();
const listener2 = vi.fn();

event.addListener(listener);

expect(event.hasListeners()).toBe(true);
expect(event.hasListener(listener)).toBe(true);
expect(event.hasListener(listener2)).toBe(false);

event.removeListener(listener);
expect(event.hasListeners()).toBe(false);
expect(event.hasListener(listener)).toBe(false);
expect(event.hasListener(listener2)).toBe(false);

event.addListener(listener2);
expect(event.hasListeners()).toBe(true);
expect(event.hasListener(listener)).toBe(false);
expect(event.hasListener(listener2)).toBe(true);

event.dispatch('yeah');

expect(listener).not.toHaveBeenCalled();
expect(listener2).toHaveBeenCalledWith('yeah');

// directly manipulate internal state
event.listeners.has(listener2);
event.listeners.clear();
expect(event.hasListeners()).toBe(false);
expect(event.hasListener(listener2)).toBe(false);

event.listeners.add(listener);
expect(event.hasListeners()).toBe(true);
expect(event.hasListener(listener)).toBe(true);
});
});
43 changes: 43 additions & 0 deletions packages/mock-chrome/src/runtime/channel.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export const exampleOrigin = 'https://example.com';
export const crxOrigin = 'chrome-extension://mockextensionid';

export const tabSender: chrome.runtime.MessageSender = {
documentId: 'a unique string',
documentLifecycle: 'active',
frameId: 0,
origin: exampleOrigin,
tlsChannelId: 'very random string',
url: exampleOrigin + '/',
tab: {
active: true,
autoDiscardable: false,
discarded: false,
groupId: -1,
highlighted: false,
id: 1337,
incognito: false,
index: 1,
pinned: false,
selected: true,
url: exampleOrigin + '/',
windowId: 1,
},
};

export const crxSender: chrome.runtime.MessageSender = {
id: 'mock-extension-id',
origin: crxOrigin,
url: crxOrigin + '/background.js',
};

export const throwDisconnectedPortError = () => {
throw new Error('Attempting to use a disconnected port object');
};

export const addTlsChannelId = (
senderBase: chrome.runtime.MessageSender,
{ includeTlsChannelId }: chrome.runtime.ConnectInfo = {},
) => ({
...senderBase,
tlsChannelId: includeTlsChannelId ? 'mock-tls-channel-id' : undefined,
});
165 changes: 165 additions & 0 deletions packages/mock-chrome/src/runtime/channel.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { MockedFunction, vi } from 'vitest';
import { MockedChromeEvent, mockEvent } from '../event.mock.js';
import {
addTlsChannelId,
crxSender,
tabSender,
throwDisconnectedPortError,
} from './channel.fixtures.js';

export type MockConnectImpl = (info?: ChromeConnectInfo) => MockedPort;
export type MockSendersImpl = (info?: ChromeConnectInfo) => {
connectSender: ChromeSender;
onConnectSender: ChromeSender;
};
export type MockPortsImpl = (
mockSenders: MockedFunction<MockSendersImpl>,
connectInfo?: ChromeConnectInfo,
) => {
connectPort: MockedPort;
onConnectPort: MockedPort;
};

export interface MockedChannel {
connect: MockedFunction<MockConnectImpl>;
onConnect: MockedChromeEvent<chrome.runtime.ExtensionConnectEvent>;
mockSenders: MockedFunction<MockSendersImpl>;
mockPorts: MockedFunction<MockPortsImpl>;
}

export type MockedPort = Omit<ChromePort, 'onDisconnect' | 'onMessage'> & {
name: string;
sender: ChromeSender;
disconnect: MockedFunction<ChromePort['disconnect']>;
postMessage: MockedFunction<ChromePort['postMessage']>;
onDisconnect: MockedChromeEvent<chrome.runtime.PortDisconnectEvent>;
onMessage: MockedChromeEvent<chrome.runtime.PortMessageEvent>;
asPort: ChromePort;
};

/**
* Create a pair of `chrome.runtime.MessageSender` objects, one for the caller
* of `chrome.runtime.connect` and one for the listener of `chrome.runtime.onConnect`.
*
* @param connectInfo info passed to the `chrome.runtime.connect` call
* @returns a pair of `chrome.runtime.MessageSender` objects
*/
export const mockSendersDefault: MockSendersImpl = (
connectInfo?: ChromeConnectInfo,
): {
connectSender: ChromeSender;
onConnectSender: ChromeSender;
} => ({
connectSender: addTlsChannelId(tabSender, connectInfo),
onConnectSender: crxSender,
});

/**
* Create a pair of ports, one for the caller of `chrome.runtime.connect` and
* one for the listener of `chrome.runtime.onConnect`.
*
* @param mockSenders function to create a pair of `chrome.runtime.MessageSender`
* @param connectInfo info passed to the `chrome.runtime.connect` call
* @param onConnectSender function to create a 'sender' representing the listener of `chrome.runtime.onConnect`
*/
export const mockPortsDefault: MockPortsImpl = (
mockSenders = mockSendersDefault,
connectInfo?: ChromeConnectInfo,
): {
connectPort: MockedPort;
onConnectPort: MockedPort;
} => {
const name = connectInfo?.name ?? 'mock-port-' + crypto.randomUUID();

const { connectSender, onConnectSender } = mockSenders(connectInfo);

const connectPort: MockedPort = {
name,
sender: onConnectSender,
onDisconnect: mockEvent<chrome.runtime.PortDisconnectEvent>(),
onMessage: mockEvent<chrome.runtime.PortMessageEvent>(),

disconnect: vi.fn<[], void>(() => {
onConnectPort.onDisconnect.dispatch(onConnectPort.asPort);
connectPort.postMessage.mockImplementation(throwDisconnectedPortError);
connectPort.disconnect.mockImplementation(() => void null);
onConnectPort.postMessage.mockImplementation(throwDisconnectedPortError);
onConnectPort.disconnect.mockImplementation(() => void null);
}),

postMessage: vi.fn<[unknown], void>(message =>
onConnectPort.onMessage.dispatch(JSON.parse(JSON.stringify(message)), onConnectPort.asPort),
),

get asPort() {
return connectPort as unknown as ChromePort;
},
};

const onConnectPort: MockedPort = {
name,
sender: connectSender,
onDisconnect: mockEvent<chrome.runtime.PortDisconnectEvent>(),
onMessage: mockEvent<chrome.runtime.PortMessageEvent>(),

disconnect: vi.fn<[], void>(() => {
connectPort.onDisconnect.dispatch(connectPort.asPort);
connectPort.postMessage.mockImplementation(throwDisconnectedPortError);
connectPort.disconnect.mockImplementation(() => void null);
onConnectPort.postMessage.mockImplementation(throwDisconnectedPortError);
onConnectPort.disconnect.mockImplementation(() => void null);
}),

postMessage: vi.fn<[unknown], void>(message =>
connectPort.onMessage.dispatch(JSON.parse(JSON.stringify(message)), connectPort.asPort),
),

get asPort() {
return onConnectPort as unknown as ChromePort;
},
};

return { connectPort, onConnectPort };
};

/**
* Call this to mock the chrome.runtime.connect and chrome.runtime.onConnect
* APIs. To avoid clobbering other stubs, they aren't automatically injected.
* You'll need to stub them like this:
*
* ```ts
* vi.stubGlobal('chrome', {
* // collect all your `chrome` stubs in the same `stubGlobal` call
* runtime: { connect: mockConnect, onConnect: mockOnConnect },
* });
* ```
*
* You may only want to stub one end, and use the other in your test functions.
*
* Each set of mocks is scoped, and each `connect` call will create a new scoped
* channel. If multiple mocks must be injected into the same global scope to
* host different scripts simultaneously, you might manage it with an
* intermediate layer of mocks.
*
* @returns a pair of mocks for chrome.runtime.connect and chrome.runtime.onConnect
*/
export const mockChannel = ({
mockSenders = vi.fn(mockSendersDefault),
mockPorts = vi.fn(mockPortsDefault),
} = {}): MockedChannel => {
// create the chrome.runtime.onConnect{...} event manager
const onConnect = mockEvent<chrome.runtime.ExtensionConnectEvent>();

// create the chrome.runtime.connect(...) function
const connect = vi.fn((info?: ChromeConnectInfo) => {
const { connectPort, onConnectPort } = mockPorts(mockSenders, info);
try {
onConnect.dispatch(onConnectPort.asPort); // send the .onConnect listener's port
} catch (e) {
console.debug('onConnect dispatch failed', e);
}
return connectPort; // return the .connect() caller's port
});

return { connect, onConnect, mockSenders, mockPorts };
};
Loading

0 comments on commit ca71c02

Please sign in to comment.