diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 046bc14..7c6cb93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.0.15 - name: Install dependencies run: bun install @@ -34,11 +36,11 @@ jobs: - name: Typecheck run: bun run typecheck - - name: Run tests - run: bun test + - name: Run tests (Bun) + run: bun run test:bun - # - name: Run Jest tests - # run: bun run test + - name: Run tests (Jest) + run: bun run test:jest - name: Build run: bun run build diff --git a/__tests__/network/utils.spec.ts b/__tests__/network/utils.spec.ts new file mode 100644 index 0000000..f4cd3e3 --- /dev/null +++ b/__tests__/network/utils.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from '@jest/globals' +import { NetworkId } from 'src/network/constants' +import { isAlgodConfig, isNetworkConfigMap, isValidNetworkId } from 'src/network/utils' + +describe('Type Guards', () => { + describe('isValidNetworkId', () => { + it('returns true for a valid NetworkId', () => { + expect(isValidNetworkId(NetworkId.TESTNET)).toBe(true) + }) + + it('returns false for an invalid NetworkId', () => { + expect(isValidNetworkId('foo')).toBe(false) + }) + }) + + describe('isAlgodConfig', () => { + it('returns true for a valid AlgodConfig', () => { + expect( + isAlgodConfig({ + token: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + baseServer: 'http://localhost', + port: 1234, + headers: { + 'X-Foo': 'bar' + } + }) + ).toBe(true) + + expect( + isAlgodConfig({ + token: '', + baseServer: '' + }) + ).toBe(true) + }) + + it('returns false for an invalid AlgodConfig', () => { + expect( + isAlgodConfig({ + baseServer: '' + }) + ).toBe(false) + + expect( + isAlgodConfig({ + token: '' + }) + ).toBe(false) + + expect( + isAlgodConfig({ + token: '', + baseServer: '', + foo: '' + }) + ).toBe(false) + }) + }) + + describe('isNetworkConfigMap', () => { + it('returns true for a valid NetworkConfigMap', () => { + const validConfigMap = { + [NetworkId.MAINNET]: { + token: '', + baseServer: '' + }, + [NetworkId.TESTNET]: { + token: '', + baseServer: '' + } + } + expect(isNetworkConfigMap(validConfigMap)).toBe(true) + }) + + it('returns false for an invalid NetworkConfigMap', () => { + expect( + isNetworkConfigMap({ + token: '', + baseServer: '' + }) + ).toBe(false) + }) + }) +}) diff --git a/__tests__/store/pubsub.spec.ts b/__tests__/store/pubsub.spec.ts new file mode 100644 index 0000000..9e98376 --- /dev/null +++ b/__tests__/store/pubsub.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { PubSub } from 'src/store/pubsub' + +describe('PubSub', () => { + let pubSub: PubSub + + beforeEach(() => { + pubSub = new PubSub() + }) + + it('should call all callbacks subscribed to an event when it is published', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + const event = 'testEvent' + const data = { key: 'value' } + + pubSub.subscribe(event, callback1) + pubSub.subscribe(event, callback2) + pubSub.publish(event, data) + + expect(callback1).toHaveBeenCalledWith(data) + expect(callback2).toHaveBeenCalledWith(data) + }) + + it('should not call callbacks subscribed to different events', () => { + const callback1 = jest.fn() + const event1 = 'testEvent1' + const event2 = 'testEvent2' + const data = { key: 'value' } + + pubSub.subscribe(event1, callback1) + pubSub.publish(event2, data) + + expect(callback1).not.toHaveBeenCalled() + }) + + it('should pass the correct data to callbacks when an event is published', () => { + const callback = jest.fn() + const event = 'testEvent' + const data1 = { key: 'value1' } + const data2 = { key: 'value2' } + + pubSub.subscribe(event, callback) + pubSub.publish(event, data1) + pubSub.publish(event, data2) + + expect(callback).toHaveBeenNthCalledWith(1, data1) + expect(callback).toHaveBeenNthCalledWith(2, data2) + }) +}) diff --git a/__tests__/store/store.spec.ts b/__tests__/store/store.spec.ts new file mode 100644 index 0000000..6be487c --- /dev/null +++ b/__tests__/store/store.spec.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { NetworkId } from 'src/network/constants' +import { StoreActions, StoreMutations, createStore, defaultState } from 'src/store' +import { LOCAL_STORAGE_KEY } from 'src/store/constants' +import { replacer } from 'src/store/utils' +import { WalletId } from 'src/wallets/supported/constants' + +// Suppress console output +jest.spyOn(console, 'info').mockImplementation(() => {}) +jest.spyOn(console, 'groupCollapsed').mockImplementation(() => {}) + +// Mock console.error +const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}) + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: any) => (store[key] = value.toString()), + clear: () => (store = {}) + } +})() +Object.defineProperty(global, 'localStorage', { + value: localStorageMock +}) + +describe('Store', () => { + beforeEach(() => { + localStorage.clear() + mockConsoleError.mockClear() + }) + + describe('Initialization', () => { + it('initializes with provided state', () => { + const initialState = { ...defaultState } + const store = createStore(initialState) + expect(store.getState()).toEqual(initialState) + }) + + it('initializes from persisted state', () => { + const persistedState = { ...defaultState, activeNetwork: NetworkId.MAINNET } + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(persistedState, replacer)) + const store = createStore(defaultState) + expect(store.getState()).toEqual(persistedState) + }) + }) + + describe('Dispatching Actions', () => { + it('dispatches a addWallet action correctly', () => { + const account = { + name: 'Defly Wallet 1', + address: 'address' + } + const walletState = { + accounts: [account], + activeAccount: account + } + const store = createStore(defaultState) + store.dispatch(StoreActions.ADD_WALLET, { + walletId: WalletId.DEFLY, + wallet: walletState + }) + expect(store.getState().wallets.get(WalletId.DEFLY)).toEqual(walletState) + }) + + it('dispatches a removeWallet action correctly', () => { + const account = { + name: 'Defly Wallet 1', + address: 'address' + } + const store = createStore({ + ...defaultState, + wallets: new Map([ + [ + WalletId.DEFLY, + { + accounts: [account], + activeAccount: account + } + ] + ]) + }) + expect(store.getState().wallets.get(WalletId.DEFLY)).toBeDefined() + store.dispatch(StoreActions.REMOVE_WALLET, { walletId: WalletId.DEFLY }) + expect(store.getState().wallets.get(WalletId.DEFLY)).toBeUndefined() + }) + + // @todo: Should fail if walletId is not in wallets map + it('dispatches a setActiveWallet action correctly', () => { + const store = createStore(defaultState) + store.dispatch(StoreActions.SET_ACTIVE_WALLET, { walletId: WalletId.DEFLY }) + expect(store.getState().activeWallet).toBe(WalletId.DEFLY) + }) + + it('dispatches a setActiveAccount action correctly', () => { + const account1 = { + name: 'Defly Wallet 1', + address: 'address1' + } + const account2 = { + name: 'Defly Wallet 2', + address: 'address2' + } + const store = createStore({ + ...defaultState, + wallets: new Map([ + [ + WalletId.DEFLY, + { + accounts: [account1, account2], + activeAccount: account1 + } + ] + ]) + }) + store.dispatch(StoreActions.SET_ACTIVE_ACCOUNT, { + walletId: WalletId.DEFLY, + address: account2.address + }) + expect(store.getState().wallets.get(WalletId.DEFLY)?.activeAccount).toEqual(account2) + }) + + it('dispatches a setActiveNetwork action correctly', () => { + const store = createStore(defaultState) + store.dispatch(StoreActions.SET_ACTIVE_NETWORK, { networkId: NetworkId.MAINNET }) + expect(store.getState().activeNetwork).toBe(NetworkId.MAINNET) + }) + + it('dispatches a setAccounts action correctly', () => { + const account1 = { + name: 'Defly Wallet 1', + address: 'address1' + } + const account2 = { + name: 'Defly Wallet 2', + address: 'address2' + } + const store = createStore({ + ...defaultState, + wallets: new Map([ + [ + WalletId.DEFLY, + { + accounts: [account1], + activeAccount: account1 + } + ] + ]) + }) + store.dispatch(StoreActions.SET_ACCOUNTS, { + walletId: WalletId.DEFLY, + accounts: [account1, account2] + }) + expect(store.getState().wallets.get(WalletId.DEFLY)?.accounts).toEqual([account1, account2]) + }) + + it('handles unknown actions correctly', () => { + const store = createStore(defaultState) + // @ts-expect-error Unknown action + const result = store.dispatch('unknownAction', {}) + expect(result).toBeFalsy() + expect(console.error).toHaveBeenCalledWith(`[Store] Action "unknownAction" doesn't exist`) + }) + }) + + describe('Committing Mutations', () => { + it('commits a addWallet mutation correctly', () => { + const account = { + name: 'Defly Wallet 1', + address: 'address' + } + const walletState = { + accounts: [account], + activeAccount: account + } + const store = createStore(defaultState) + store.commit(StoreMutations.ADD_WALLET, { + walletId: WalletId.DEFLY, + wallet: walletState + }) + expect(store.getState().wallets.get(WalletId.DEFLY)).toEqual(walletState) + }) + + it('commits a removeWallet mutation correctly', () => { + const account = { + name: 'Defly Wallet 1', + address: 'address' + } + const store = createStore({ + ...defaultState, + wallets: new Map([ + [ + WalletId.DEFLY, + { + accounts: [account], + activeAccount: account + } + ] + ]) + }) + expect(store.getState().wallets.get(WalletId.DEFLY)).toBeDefined() + store.commit(StoreMutations.REMOVE_WALLET, { walletId: WalletId.DEFLY }) + expect(store.getState().wallets.get(WalletId.DEFLY)).toBeUndefined() + }) + + // @todo: Should fail if walletId is not in wallets map + it('commits a setActiveWallet mutation correctly', () => { + const store = createStore(defaultState) + store.commit(StoreMutations.SET_ACTIVE_WALLET, { walletId: WalletId.DEFLY }) + expect(store.getState().activeWallet).toBe(WalletId.DEFLY) + }) + + it('commits a setActiveAccount mutation correctly', () => { + const account1 = { + name: 'Defly Wallet 1', + address: 'address1' + } + const account2 = { + name: 'Defly Wallet 2', + address: 'address2' + } + const store = createStore({ + ...defaultState, + wallets: new Map([ + [ + WalletId.DEFLY, + { + accounts: [account1, account2], + activeAccount: account1 + } + ] + ]) + }) + store.commit(StoreMutations.SET_ACTIVE_ACCOUNT, { + walletId: WalletId.DEFLY, + address: account2.address + }) + expect(store.getState().wallets.get(WalletId.DEFLY)?.activeAccount).toEqual(account2) + }) + + it('commits a setActiveNetwork mutation correctly', () => { + const store = createStore(defaultState) + store.commit(StoreMutations.SET_ACTIVE_NETWORK, { networkId: NetworkId.MAINNET }) + expect(store.getState().activeNetwork).toBe(NetworkId.MAINNET) + }) + + it('handles unknown mutations correctly', () => { + const store = createStore(defaultState) + // @ts-expect-error Unknown mutation + const result = store.commit('unknownMutation', {}) + expect(result).toBeFalsy() + expect(console.error).toHaveBeenCalledWith(`[Store] Mutation "unknownMutation" doesn't exist`) + }) + }) + + describe('State Persistence', () => { + it('saves state to local storage', () => { + const store = createStore(defaultState) + store.commit(StoreMutations.SET_ACTIVE_NETWORK, { networkId: NetworkId.MAINNET }) + store.savePersistedState() + const storedState = localStorage.getItem(LOCAL_STORAGE_KEY) + expect(storedState).toEqual(JSON.stringify(store.getState(), replacer)) + }) + + it('loads state from local storage', () => { + const persistedState = { ...defaultState, activeNetwork: NetworkId.MAINNET } + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(persistedState, replacer)) + const store = createStore(defaultState) + expect(store.getState()).toEqual(persistedState) + }) + }) + + describe('Proxy Behavior', () => { + it('updates state through proxy', () => { + const store = createStore(defaultState) + const newState = { ...store.getState(), activeNetwork: NetworkId.MAINNET } + store.commit(StoreMutations.SET_ACTIVE_NETWORK, { networkId: NetworkId.MAINNET }) + expect(store.getState()).toEqual(newState) + }) + }) +}) diff --git a/__tests__/store/utils.spec.ts b/__tests__/store/utils.spec.ts new file mode 100644 index 0000000..62f2eed --- /dev/null +++ b/__tests__/store/utils.spec.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from '@jest/globals' +import { NetworkId } from 'src/network/constants' +import { + isValidState, + isValidWalletAccount, + isValidWalletId, + isValidWalletState, + replacer, + reviver +} from 'src/store/utils' +import { WalletId } from 'src/wallets/supported/constants' + +describe('Type Guards', () => { + describe('isValidWalletId', () => { + it('returns true for a valid WalletId', () => { + expect(isValidWalletId(WalletId.DEFLY)).toBe(true) + }) + + it('returns false for an invalid WalletId', () => { + expect(isValidWalletId('foo')).toBe(false) + }) + }) + + describe('isValidWalletAccount', () => { + it('returns true for a valid WalletAccount', () => { + expect( + isValidWalletAccount({ + name: 'Defly Wallet 1', + address: 'address' + }) + ).toBe(true) + }) + + it('returns false for an invalid WalletAccount', () => { + expect(isValidWalletAccount('foo')).toBe(false) + expect(isValidWalletAccount(null)).toBe(false) + + expect( + isValidWalletAccount({ + name: 'Defly Wallet 1', + address: 123 + }) + ).toBe(false) + + expect( + isValidWalletAccount({ + address: 'address' + }) + ).toBe(false) + }) + }) + + describe('isValidWalletState', () => { + it('returns true for a valid WalletState', () => { + expect( + isValidWalletState({ + accounts: [ + { + name: 'Defly Wallet 1', + address: 'address' + } + ], + activeAccount: { + name: 'Defly Wallet 1', + address: 'address' + } + }) + ).toBe(true) + + expect( + isValidWalletState({ + accounts: [], + activeAccount: null + }) + ).toBe(true) + }) + + it('returns false for an invalid WalletState', () => { + expect(isValidWalletState('foo')).toBe(false) + expect(isValidWalletState(null)).toBe(false) + }) + + it('returns false if accounts is invalid', () => { + expect( + isValidWalletState({ + accounts: null, + activeAccount: { + name: 'Defly Wallet 1', + address: 'address' + } + }) + ).toBe(false) + + expect( + isValidWalletState({ + activeAccount: { + name: 'Defly Wallet 1', + address: 'address' + } + }) + ).toBe(false) + }) + + it('returns false if activeAccount is invalid', () => { + expect( + isValidWalletState({ + accounts: [ + { + name: 'Defly Wallet 1', + address: 'address' + } + ], + activeAccount: 'address' + }) + ).toBe(false) + + expect( + isValidWalletState({ + accounts: [ + { + name: 'Defly Wallet 1', + address: 'address' + } + ] + }) + ).toBe(false) + }) + }) + + describe('isValidState', () => { + it('returns true for a valid state', () => { + const defaultState = { + wallets: new Map(), + activeWallet: null, + activeNetwork: NetworkId.TESTNET + } + expect(isValidState(defaultState)).toBe(true) + + const state = { + wallets: new Map([ + [ + WalletId.DEFLY, + { + accounts: [ + { + name: 'Defly Wallet 1', + address: 'address' + }, + { + name: 'Defly Wallet 2', + address: 'address' + } + ], + activeAccount: { + name: 'Defly Wallet 1', + address: 'address' + } + } + ], + [ + WalletId.PERA, + { + accounts: [ + { + name: 'Pera Wallet 1', + address: 'address' + } + ], + activeAccount: { + name: 'Pera Wallet 1', + address: 'address' + } + } + ] + ]), + activeWallet: WalletId.DEFLY, + activeNetwork: NetworkId.TESTNET + } + expect(isValidState(state)).toBe(true) + }) + + it('returns false for an invalid state', () => { + expect(isValidState('foo')).toBe(false) + expect(isValidState(null)).toBe(false) + + expect( + isValidState({ + activeWallet: WalletId.DEFLY, + activeNetwork: NetworkId.TESTNET + }) + ).toBe(false) + + expect( + isValidState({ + wallets: new Map(), + activeNetwork: NetworkId.TESTNET + }) + ).toBe(false) + + expect( + isValidState({ + wallets: new Map(), + activeWallet: WalletId.DEFLY + }) + ).toBe(false) + }) + }) +}) + +describe('Serialization and Deserialization', () => { + it('correctly serializes and deserializes a state with Map', () => { + const originalState = { + wallets: new Map([[WalletId.DEFLY, { accounts: [], activeAccount: null }]]) + } + const serializedState = JSON.stringify(originalState, replacer) + const deserializedState = JSON.parse(serializedState, reviver) + + expect(deserializedState).toEqual(originalState) + expect(deserializedState.wallets instanceof Map).toBe(true) + }) +}) diff --git a/__tests__/wallets/manager.spec.ts b/__tests__/wallets/manager.spec.ts new file mode 100644 index 0000000..375b272 --- /dev/null +++ b/__tests__/wallets/manager.spec.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { NetworkId } from 'src/network/constants' +import { LOCAL_STORAGE_KEY } from 'src/store/constants' +import { replacer } from 'src/store/utils' +import { WalletManager } from 'src/wallets/manager' +import { WalletId } from 'src/wallets/supported/constants' +import { DeflyWallet } from 'src/wallets/supported/defly' +import { PeraWallet } from 'src/wallets/supported/pera' + +// Suppress console output +jest.spyOn(console, 'info').mockImplementation(() => {}) +jest.spyOn(console, 'warn').mockImplementation(() => {}) +jest.spyOn(console, 'error').mockImplementation(() => {}) +jest.spyOn(console, 'groupCollapsed').mockImplementation(() => {}) + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: any) => (store[key] = value.toString()), + clear: () => (store = {}) + } +})() +Object.defineProperty(global, 'localStorage', { + value: localStorageMock +}) + +const deflyResumeSession = jest + .spyOn(DeflyWallet.prototype, 'resumeSession') + .mockImplementation(() => Promise.resolve()) +const peraResumeSession = jest + .spyOn(PeraWallet.prototype, 'resumeSession') + .mockImplementation(() => Promise.resolve()) + +describe('WalletManager', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('constructor', () => { + it('initializes with default network and wallets', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + expect(manager.wallets.length).toBe(2) + expect(manager.activeNetwork).toBe(NetworkId.TESTNET) + expect(manager.algodClient).toBeDefined() + }) + + it('initializes with custom network and wallets', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA], + network: NetworkId.MAINNET + }) + expect(manager.wallets.length).toBe(2) + expect(manager.activeNetwork).toBe(NetworkId.MAINNET) + expect(manager.algodClient).toBeDefined() + }) + + it('initializes with custom algod config', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA], + network: NetworkId.LOCALNET, + algod: { + baseServer: 'http://localhost', + port: '1234', + token: '1234', + headers: { + 'X-API-Key': '1234' + } + } + }) + + expect(manager.wallets.length).toBe(2) + expect(manager.activeNetwork).toBe(NetworkId.LOCALNET) + expect(manager.algodClient).toBeDefined() + }) + }) + + describe('initializeWallets', () => { + it('initializes wallets from string array', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + expect(manager.wallets.length).toBe(2) + }) + + it('initializes wallets from WalletIdConfig array', () => { + const manager = new WalletManager({ + wallets: [ + { + id: WalletId.DEFLY, + options: { + shouldShowSignTxnToast: false + } + }, + { + id: WalletId.WALLETCONNECT, + options: { + projectId: '1234' + } + } + ] + }) + expect(manager.wallets.length).toBe(2) + }) + + it('initializes wallets from mixed array', () => { + const manager = new WalletManager({ + wallets: [ + WalletId.DEFLY, + { + id: WalletId.WALLETCONNECT, + options: { + projectId: '1234' + } + } + ] + }) + expect(manager.wallets.length).toBe(2) + }) + + it('initializes wallets with custom metadata', () => { + const manager = new WalletManager({ + wallets: [ + { + id: WalletId.DEFLY, + metadata: { + name: 'Custom Wallet', + icon: 'icon' + } + } + ] + }) + expect(manager.wallets.length).toBe(1) + expect(manager.wallets[0]?.metadata.name).toBe('Custom Wallet') + expect(manager.wallets[0]?.metadata.icon).toBe('icon') + }) + + // @todo: Test for handling of invalid wallet configurations + }) + + describe('setActiveNetwork', () => { + it('sets active network correctly', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + manager.setActiveNetwork(NetworkId.MAINNET) + expect(manager.activeNetwork).toBe(NetworkId.MAINNET) + }) + + // @todo: Test for handling of invalid network + }) + + describe('subscribe', () => { + it('adds and removes a subscriber', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + const callback = jest.fn() + const unsubscribe = manager.subscribe(callback) + + // Trigger a state change + manager.setActiveNetwork(NetworkId.MAINNET) + + expect(callback).toHaveBeenCalled() + + unsubscribe() + // Trigger another state change + manager.setActiveNetwork(NetworkId.BETANET) + + expect(callback).toHaveBeenCalledTimes(1) // Should not be called again + }) + }) + + describe('activeWallet', () => { + const initialState = { + wallets: new Map([ + [ + WalletId.PERA, + { + accounts: [ + { + name: 'Pera Wallet 1', + address: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q' + }, + { + name: 'Pera Wallet 2', + address: 'N2C374IRX7HEX2YEQWJBTRSVRHRUV4ZSF76S54WV4COTHRUNYRCI47R3WU' + } + ], + activeAccount: { + name: 'Pera Wallet 1', + address: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q' + } + } + ] + ]), + activeWallet: WalletId.PERA, + activeNetwork: NetworkId.TESTNET + } + + beforeEach(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(initialState, replacer)) + }) + + it('returns the active wallet', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + expect(manager.activeWallet?.id).toBe(WalletId.PERA) + }) + + it('returns null if no active wallet', () => { + localStorage.clear() + + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + expect(manager.activeWallet).toBeNull() + }) + + it('returns active wallet accounts', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + expect(manager.activeWalletAccounts?.length).toBe(2) + expect(manager.activeWalletAddresses).toEqual([ + '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + 'N2C374IRX7HEX2YEQWJBTRSVRHRUV4ZSF76S54WV4COTHRUNYRCI47R3WU' + ]) + }) + + it('removes wallets in state that are not in config', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY] + }) + expect(manager.wallets.length).toBe(1) + expect(manager.wallets[0]?.id).toBe(WalletId.DEFLY) + expect(manager.activeWallet).toBeNull() + }) + }) + + describe('Transaction Signing', () => { + it('throws error if no active wallet', () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + expect(() => manager.signTransactions).toThrow() + }) + + // @todo: Tests for successful signing + }) + + describe('resumeSessions', () => { + it('resumes sessions for all wallets', async () => { + const manager = new WalletManager({ + wallets: [WalletId.DEFLY, WalletId.PERA] + }) + await manager.resumeSessions() + + expect(deflyResumeSession).toHaveBeenCalled() + expect(peraResumeSession).toHaveBeenCalled() + }) + }) +}) diff --git a/__tests__/wallets/utils.spec.ts b/__tests__/wallets/utils.spec.ts new file mode 100644 index 0000000..c7f4993 --- /dev/null +++ b/__tests__/wallets/utils.spec.ts @@ -0,0 +1,361 @@ +import algosdk from 'algosdk' +import { describe, expect, it } from '@jest/globals' +import { + compareAccounts, + deepMerge, + formatJsonRpcRequest, + isSignedTxnObject, + isTransaction, + mergeSignedTxnsWithGroup, + normalizeTxnGroup, + shouldSignTxnObject +} from 'src/wallets/utils' + +describe('compareAccounts', () => { + it('should return true if both account lists have the same wallet accounts', () => { + const accounts1 = [ + { name: 'Acct 1', address: 'addr1' }, + { name: 'Acct 2', address: 'addr2' } + ] + const accounts2 = [ + { name: 'Acct 2', address: 'addr2' }, + { name: 'Acct 1', address: 'addr1' } + ] + + expect(compareAccounts(accounts1, accounts2)).toBe(true) + }) + + it('should return false if account lists have different wallet accounts', () => { + const accounts1 = [ + { name: 'Acct 1', address: 'addr1' }, + { name: 'Acct 2', address: 'addr2' } + ] + const accounts2 = [ + { name: 'Acct 3', address: 'addr3' }, + { name: 'Acct 1', address: 'addr1' } + ] + + expect(compareAccounts(accounts1, accounts2)).toBe(false) + }) + + it('should return false if account lists have different sizes', () => { + const accounts1 = [ + { name: 'Acct 1', address: 'addr1' }, + { name: 'Acct 2', address: 'addr2' } + ] + const accounts2 = [{ name: 'Acct 1', address: 'addr1' }] + + expect(compareAccounts(accounts1, accounts2)).toBe(false) + }) +}) + +describe('isTransaction', () => { + const transaction = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 847, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + + const uInt8Array = transaction.toByte() + + it('should return true if the item is a single Transaction', () => { + expect(isTransaction(transaction)).toBe(true) + }) + + it('should return true if the item is an array of transactions', () => { + expect(isTransaction([transaction, transaction])).toBe(true) + }) + + it('should return false if the item is a single Uint8Array', () => { + expect(isTransaction(uInt8Array)).toBe(false) + }) + + it('should return false if the item is an array of Uint8Arrays', () => { + expect(isTransaction([uInt8Array, uInt8Array])).toBe(false) + }) +}) + +describe('isSignedTxnObject', () => { + const transaction = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 847, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + + const encodedTxn = { + amt: transaction.amount, + fee: transaction.fee, + fv: transaction.firstRound, + lv: transaction.lastRound, + snd: Buffer.from(transaction.from.publicKey), + type: 'pay', + gen: transaction.genesisID, + gh: transaction.genesisHash, + grp: Buffer.from(new Uint8Array(0)) + } + + const encodedSignedTxn = { txn: encodedTxn, sig: Buffer.from('sig') } + + it('should return true if the object is a signed transaction', () => { + expect(isSignedTxnObject(encodedSignedTxn)).toBe(true) + }) + + it('should return false if the object is not a signed transaction', () => { + expect(isSignedTxnObject(encodedTxn)).toBe(false) + }) +}) + +describe('normalizeTxnGroup', () => { + it('should throw an error if the transaction group is empty', () => { + expect(() => normalizeTxnGroup([])).toThrow('Empty transaction group!') + }) + + const transaction1 = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 1000, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + const transaction2 = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 2000, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + + describe('with algosdk.Transaction[]', () => { + it('should return an array of Uint8Arrays for a single array of transactions', () => { + const txnGroup = [transaction1, transaction2] + + const normalized = normalizeTxnGroup(txnGroup) + expect(normalized).toBeInstanceOf(Array) + expect(normalized.every((item) => item instanceof Uint8Array)).toBe(true) + }) + }) + + describe('with algosdk.Transaction[][]', () => { + it('should return a flat array of Uint8Arrays for a nested array of transactions', () => { + const txnGroup = [[transaction1], [transaction2]] + + const normalized = normalizeTxnGroup(txnGroup) + expect(normalized).toBeInstanceOf(Array) + expect(normalized.length).toBe(2) + expect(normalized.every((item) => item instanceof Uint8Array)).toBe(true) + }) + }) + + const uInt8Array1 = transaction1.toByte() + const uInt8Array2 = transaction2.toByte() + + describe('with Uint8Array[]', () => { + it('should return the same array of Uint8Arrays if input is a single array of Uint8Arrays', () => { + const txnGroup = [uInt8Array1, uInt8Array2] + + const normalized = normalizeTxnGroup(txnGroup) + expect(normalized).toEqual(txnGroup) + }) + }) + + describe('with Uint8Array[][]', () => { + it('should return a flat array of Uint8Arrays for a nested array of Uint8Arrays', () => { + const txnGroup = [[uInt8Array1], [uInt8Array2]] + + const normalized = normalizeTxnGroup(txnGroup) + expect(normalized).toBeInstanceOf(Array) + expect(normalized.length).toBe(2) + expect(normalized.every((item) => item instanceof Uint8Array)).toBe(true) + }) + }) +}) + +describe('shouldSignTxnObject', () => { + const transaction = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 847, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + + const encodedTxn = { + amt: transaction.amount, + fee: transaction.fee, + fv: transaction.firstRound, + lv: transaction.lastRound, + snd: Buffer.from(transaction.from.publicKey), + type: 'pay', + gen: transaction.genesisID, + gh: transaction.genesisHash, + grp: Buffer.from(new Uint8Array(0)) + } + + const addresses = ['7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q'] + + it('should return true if the transaction object is not signed and indexesToSign is undefined', () => { + const indexesToSign = undefined + const idx = 0 + + expect(shouldSignTxnObject(encodedTxn, addresses, indexesToSign, idx)).toBe(true) + }) + + it('should return true if the transaction object is not signed and indexesToSign includes the index', () => { + const indexesToSign = [0] + const idx = 0 + + expect(shouldSignTxnObject(encodedTxn, addresses, indexesToSign, idx)).toBe(true) + }) + + it('should return false if the transaction object is not signed and indexesToSign does not include the index', () => { + const indexesToSign = [1] + const idx = 0 + + expect(shouldSignTxnObject(encodedTxn, addresses, indexesToSign, idx)).toBe(false) + }) + + it('should return false if the transaction object is signed', () => { + const indexesToSign = undefined + const idx = 0 + const encodedSignedTxn = { txn: encodedTxn, sig: Buffer.from('sig') } + + expect(shouldSignTxnObject(encodedSignedTxn, addresses, indexesToSign, idx)).toBe(false) + }) + + it('should return false if addresses do not include the sender address', () => { + const indexesToSign = undefined + const idx = 0 + const addresses = ['addr1', 'addr2'] + + expect(shouldSignTxnObject(encodedTxn, addresses, indexesToSign, idx)).toBe(false) + }) +}) + +describe('mergeSignedTxnsWithGroup', () => { + const transaction1 = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 1000, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + const transaction2 = new algosdk.Transaction({ + from: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + to: '7ZUECA7HFLZTXENRV24SHLU4AVPUTMTTDUFUBNBD64C73F3UHRTHAIOF6Q', + fee: 10, + amount: 2000, + firstRound: 51, + lastRound: 61, + genesisHash: 'JgsgCaCTqIaLeVhyL6XlRu3n7Rfk2FxMeK+wRSaQ7dI=', + genesisID: 'testnet-v1.0' + }) + + const uInt8Array1 = transaction1.toByte() + const uInt8Array2 = transaction2.toByte() + + it('should merge all signed transactions into the group when all are signed', () => { + const signedTxn1 = new Uint8Array(Buffer.from('signedTxn1Str', 'base64')) + const signedTxn2 = new Uint8Array(Buffer.from('signedTxn2Str', 'base64')) + const txnGroup = [uInt8Array1, uInt8Array2] + const signedIndexes = [0, 1] + const returnGroup = true + + const merged = mergeSignedTxnsWithGroup( + [signedTxn1, signedTxn2], + txnGroup, + signedIndexes, + returnGroup + ) + expect(merged).toEqual([signedTxn1, signedTxn2]) + }) + + it('should merge all signed transactions into the group when only some are signed', () => { + const signedTxn1 = new Uint8Array(Buffer.from('signedTxn1Str', 'base64')) + const txnGroup = [uInt8Array1, uInt8Array2] + const signedIndexes = [0] + const returnGroup = true + + const merged = mergeSignedTxnsWithGroup([signedTxn1], txnGroup, signedIndexes, returnGroup) + expect(merged).toEqual([signedTxn1, uInt8Array2]) + }) + + it('should merge all signed transactions into the group when none are signed', () => { + const txnGroup = [uInt8Array1, uInt8Array2] + const returnGroup = true + + const merged = mergeSignedTxnsWithGroup([], txnGroup, [], returnGroup) + expect(merged).toEqual(txnGroup) + }) + + it('should only return signed transactions if returnGroup is false', () => { + const signedTxn1 = new Uint8Array(Buffer.from('signedTxn1Str', 'base64')) + const signedTxn2 = new Uint8Array(Buffer.from('signedTxn2Str', 'base64')) + const txnGroup = [uInt8Array1, uInt8Array2] + const returnGroup = false + + const signedIndexes1 = [0, 1] + + const merged1 = mergeSignedTxnsWithGroup( + [signedTxn1, signedTxn2], + txnGroup, + signedIndexes1, + returnGroup + ) + expect(merged1).toEqual([signedTxn1, signedTxn2]) + + const signedIndexes2 = [0] + + const merged2 = mergeSignedTxnsWithGroup([signedTxn1], txnGroup, signedIndexes2, returnGroup) + expect(merged2).toEqual([signedTxn1]) + }) +}) + +describe('deepMerge', () => { + it('should deeply merge two objects', () => { + const target = { a: 1, b: { c: 2 } } + const source = { b: { d: 3 }, e: 4 } + const expected = { a: 1, b: { c: 2, d: 3 }, e: 4 } + + expect(deepMerge(target, source)).toEqual(expected) + }) + + it('should throw an error if either argument is not an object', () => { + expect(() => deepMerge(null, {})).toThrow('Target and source must be objects') + expect(() => deepMerge({}, null)).toThrow('Target and source must be objects') + }) +}) + +describe('formatJsonRpcRequest', () => { + it('should format a JSON-RPC request with the given method and params', () => { + const method = 'algo_signTxn' + const params = [{ txn: 'base64Txn' }] + const request = formatJsonRpcRequest(method, params) + + expect(request).toHaveProperty('id') + expect(request).toHaveProperty('jsonrpc', '2.0') + expect(request).toHaveProperty('method', method) + expect(request).toHaveProperty('params', params) + }) +}) diff --git a/bun.lockb b/bun.lockb index a784004..459a75e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.cjs b/jest.config.cjs index 5903457..babf6d3 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,3 +1,4 @@ +/* global module */ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest/presets/default-esm', @@ -11,6 +12,7 @@ module.exports = { ] }, extensionsToTreatAsEsm: ['.ts'], + testRegex: '(/__tests__/.*\\.spec\\.ts$|(\\.|/)(spec)\\.ts$)', // Match .spec.ts files only moduleNameMapper: { // Map TypeScript path aliases '^src/(.*)$': '/src/$1', diff --git a/package.json b/package.json index 3044efb..252bde0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "tsup", "start": "bun run build -- --watch", - "test": "node --experimental-vm-modules node_modules/.bin/jest", + "test:bun": "bun test .test.", + "test:jest": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/.bin/jest", "lint": "eslint '**/*.{js,ts}'", "prettier": "prettier --check '**/*.{js,ts}'", "typecheck": "tsc --noEmit" @@ -66,6 +67,7 @@ }, "devDependencies": { "@blockshake/defly-connect": "^1.1.6", + "@jest/globals": "^29.7.0", "@perawallet/connect": "^1.3.3", "@randlabs/myalgo-connect": "^1.4.2", "@types/jest": "^29.5.10",