import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { WalletMetadata } from '@meshconnect/uwc-types' import { solanaNetwork } from '@meshconnect/uwc-constants' // Registry mock — getWallets().get() returns whatever we stage. let registryWallets: any[] = [] vi.mock('@wallet-standard/app', () => ({ getWallets: () => ({ get: () => registryWallets }) })) vi.mock('@solana/wallet-adapter-base', () => ({ isWalletAdapterCompatibleStandardWallet: vi.fn(() => true) })) vi.mock('@solana/wallet-standard-wallet-adapter-base', () => ({ StandardWalletAdapter: class { wallet: any constructor(opts: any) { this.wallet = opts.wallet } } })) // Isolate discovery from the real adapter. We control the legacy-provider signal // per injectedId. vi.hoisted: the factory is hoisted above the file. const { getInjectedSolanaProvider, resolveInjectedProperty, MockLegacyAdapter } = vi.hoisted(() => ({ getInjectedSolanaProvider: vi.fn(), resolveInjectedProperty: vi.fn(), MockLegacyAdapter: class { constructor( public injectedId: string, public name: string ) {} } })) vi.mock('./legacy-injected-solana-adapter', () => ({ getInjectedSolanaProvider: (id: string) => getInjectedSolanaProvider(id), resolveInjectedProperty: (id: string) => resolveInjectedProperty(id), LegacyInjectedSolanaWalletAdapter: MockLegacyAdapter })) import { getSolanaWallets } from './wallet-standard-discovery' // Derive from the shared constant (discovery uses `solanaNetwork.id`) so the // test can't drift if the canonical Solana CAIP-2 id ever changes. const SOLANA_MAINNET = solanaNetwork.id function standardWallet( name: string, chains: string[] = [SOLANA_MAINNET] ): any { return { name, chains, features: { 'solana:signTransaction': {} } } } // Minimal WalletMetadata carrying a solana injectedId + walletStandardName. function expectedWallet( walletStandardName?: string, injectedId?: string ): WalletMetadata { return { extensionInjectedProvider: { namespaceMetaData: { solana: { walletStandardName, injectedId } } } } as unknown as WalletMetadata } describe('getSolanaWallets — metadata-driven legacy injected fallback (MFS-805)', () => { beforeEach(() => { registryWallets = [] getInjectedSolanaProvider.mockReset() resolveInjectedProperty.mockReset() // default: the legacy global resolves to a provider getInjectedSolanaProvider.mockReturnValue({ connect: vi.fn() }) // default: no raw global present unless a test stages one resolveInjectedProperty.mockReturnValue(undefined) }) afterEach(() => { delete (window as any).UWCBridgeChildInitialized delete (window as any).walletStandardWallets }) it('adds a legacy entry from solana.injectedId + walletStandardName (Legacy mode)', async () => { const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets).toHaveLength(1) const w = wallets[0]! expect(w.name).toBe('Base Wallet') expect(w.chains).toEqual([SOLANA_MAINNET]) expect(w.uuid).toBe(`base-wallet-${SOLANA_MAINNET.toLowerCase()}`) expect(w.adapter).toBeInstanceOf(MockLegacyAdapter) // wallet-agnostic: adapter constructed from the metadata injectedId + name expect((w.adapter as any).injectedId).toBe('coinbaseSolana') expect((w.adapter as any).name).toBe('Base Wallet') expect(getInjectedSolanaProvider).toHaveBeenCalledWith('coinbaseSolana') // Telemetry signal (MFS-805): legacy-fallback entries are tagged so detection // can report the legacy path was exercised. expect(w.legacyInjected).toBe(true) }) it('adds nothing when no expected wallet declares a solana injectedId', async () => { const wallets = await getSolanaWallets([expectedWallet('Base Wallet')]) expect(wallets).toHaveLength(0) }) it('adds nothing when walletStandardName is missing (needed for core matching)', async () => { const wallets = await getSolanaWallets([ expectedWallet(undefined, 'coinbaseSolana') ]) expect(wallets).toHaveLength(0) }) it('adds nothing when the injectedId global does not resolve to a provider', async () => { getInjectedSolanaProvider.mockReturnValue(undefined) const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets).toHaveLength(0) }) it('does NOT duplicate when a registry wallet already claims the name', async () => { registryWallets = [standardWallet('Base Wallet')] const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets).toHaveLength(1) expect(wallets[0]!.adapter).not.toBeInstanceOf(MockLegacyAdapter) // Registry wallets are NOT tagged legacyInjected — the flag must stay unique // to the fallback path so the telemetry count can't over-report. expect(wallets[0]!.legacyInjected).toBeFalsy() }) it('dedupes against a registry wallet case-insensitively (matches core matching)', async () => { // Registry name and metadata name differ only by casing — core matches // Solana names case-insensitively, so no duplicate legacy entry. registryWallets = [standardWallet('Base Wallet')] const wallets = await getSolanaWallets([ expectedWallet('BASE WALLET', 'coinbaseSolana') ]) expect(wallets).toHaveLength(1) expect(wallets[0]!.adapter).not.toBeInstanceOf(MockLegacyAdapter) }) it('returns [] outside a browser (no window)', async () => { vi.stubGlobal('window', undefined) try { const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets).toEqual([]) } finally { vi.unstubAllGlobals() } }) it('adds the legacy entry alongside other registry Solana wallets', async () => { registryWallets = [standardWallet('Phantom')] const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets.map(w => w.name).sort()).toEqual(['Base Wallet', 'Phantom']) }) it('keys a registry wallet uuid off its solana: chain, not chains[0]', async () => { // Multi-chain wallet that lists a non-Solana chain first: the uuid must // still derive from the solana: chain so it is stable regardless of the // wallet's chain ordering. registryWallets = [ standardWallet('Multi Wallet', ['eip155:1', SOLANA_MAINNET]) ] const wallets = await getSolanaWallets([]) expect(wallets).toHaveLength(1) expect(wallets[0]!.uuid).toBe( `multi-wallet-${SOLANA_MAINNET.toLowerCase()}` ) expect(wallets[0]!.uuid).not.toContain('eip155') }) it('is wallet-agnostic — works for any injectedId/name, not just Coinbase', async () => { const wallets = await getSolanaWallets([ expectedWallet('Acme Wallet', 'acmeSolana') ]) expect(wallets[0]!.name).toBe('Acme Wallet') expect((wallets[0]!.adapter as any).injectedId).toBe('acmeSolana') }) it('names the legacy entry from the in-wallet provider when usingIntegratedBrowser', async () => { // Coinbase/Base: extension solana name "Base Wallet", in-wallet "Base". // Core matches the in-wallet name in the integrated-browser flow, so the // synthetic entry must be named "Base" for the match to succeed. const meta = { extensionInjectedProvider: { namespaceMetaData: { solana: { walletStandardName: 'Base Wallet', injectedId: 'coinbaseSolana' } } }, integratedBrowserInjectedProvider: { namespaceMetaData: { solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' } } } } as unknown as WalletMetadata const wallets = await getSolanaWallets([meta], true) expect(wallets).toHaveLength(1) expect(wallets[0]!.name).toBe('Base') expect(wallets[0]!.uuid).toBe(`base-${SOLANA_MAINNET.toLowerCase()}`) expect((wallets[0]!.adapter as { name: string }).name).toBe('Base') // injectedId is read from either provider (identical) — still resolves. expect(getInjectedSolanaProvider).toHaveBeenCalledWith('coinbaseSolana') }) it('resolves from the in-wallet provider when there is no extension metadata', async () => { // Integrated-browser-only session: no extensionInjectedProvider at all, the // injectedId + name live only on integratedBrowserInjectedProvider. const meta = { integratedBrowserInjectedProvider: { namespaceMetaData: { solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' } } } } as unknown as WalletMetadata const wallets = await getSolanaWallets([meta], true) expect(wallets).toHaveLength(1) expect(wallets[0]!.name).toBe('Base') expect(getInjectedSolanaProvider).toHaveBeenCalledWith('coinbaseSolana') }) it('produces NO entry in the in-wallet flow when the solana block lives only on the extension provider', async () => { // Discovery names ONLY from the session-matched provider (mirrors core's // pickProvider). With usingIntegratedBrowser and the solana block only on // the extension provider, core would read the integrated provider and never // match — so a cross-provider entry would be dead. We honestly skip it. const wallets = await getSolanaWallets( [expectedWallet('Base Wallet', 'coinbaseSolana')], true ) expect(wallets).toHaveLength(0) }) it('uses the extension name when not usingIntegratedBrowser even if an in-wallet name exists', async () => { const meta = { extensionInjectedProvider: { namespaceMetaData: { solana: { walletStandardName: 'Base Wallet', injectedId: 'coinbaseSolana' } } }, integratedBrowserInjectedProvider: { namespaceMetaData: { solana: { walletStandardName: 'Base', injectedId: 'coinbaseSolana' } } } } as unknown as WalletMetadata const wallets = await getSolanaWallets([meta], false) expect(wallets[0]!.name).toBe('Base Wallet') }) it('reports a discovery failure when the legacy global is present but the wrong shape', async () => { // Global exists at window[injectedId] but isn't a valid Solana provider. getInjectedSolanaProvider.mockReturnValue(undefined) resolveInjectedProperty.mockReturnValue({ foo: 'bar' }) const onFailure = vi.fn() const wallets = await getSolanaWallets( [expectedWallet('Base Wallet', 'coinbaseSolana')], false, onFailure ) expect(wallets).toHaveLength(0) expect(onFailure).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', walletName: 'Base Wallet' }) }) it('does NOT report a failure when the legacy global is simply absent (not installed)', async () => { getInjectedSolanaProvider.mockReturnValue(undefined) resolveInjectedProperty.mockReturnValue(undefined) const onFailure = vi.fn() const wallets = await getSolanaWallets( [expectedWallet('Base Wallet', 'coinbaseSolana')], false, onFailure ) expect(wallets).toHaveLength(0) expect(onFailure).not.toHaveBeenCalled() }) it('does NOT report a failure when the legacy global is null', async () => { getInjectedSolanaProvider.mockReturnValue(undefined) resolveInjectedProperty.mockReturnValue(null) const onFailure = vi.fn() const wallets = await getSolanaWallets( [expectedWallet('Base Wallet', 'coinbaseSolana')], false, onFailure ) expect(wallets).toHaveLength(0) expect(onFailure).not.toHaveBeenCalled() }) it('does NOT report a failure when the legacy provider resolves normally', async () => { const onFailure = vi.fn() const wallets = await getSolanaWallets( [expectedWallet('Base Wallet', 'coinbaseSolana')], false, onFailure ) expect(wallets).toHaveLength(1) expect(onFailure).not.toHaveBeenCalled() }) it('dedupes across multiple expected wallets with the same name', async () => { const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana'), expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets).toHaveLength(1) }) it('uses the bridge list and skips the legacy fallback when framed', async () => { ;(window as any).UWCBridgeChildInitialized = true ;(window as any).walletStandardWallets = [ { uuid: 'bridge-sol', name: 'Bridged Phantom', chains: [], features: [] } ] const wallets = await getSolanaWallets([ expectedWallet('Base Wallet', 'coinbaseSolana') ]) expect(wallets.map(w => w.name)).toEqual(['Bridged Phantom']) expect(getInjectedSolanaProvider).not.toHaveBeenCalled() }) })