import { describe, it, expect, vi, beforeEach } from 'vitest' import type { WalletConnectConfig } from '@meshconnect/uwc-types' import { RELAYER_EVENTS } from '@walletconnect/core' // Relayer error handlers registered by the client under test, keyed by event name. const relayerHandlers: Record void> = {} const fakeClient = { core: { relayer: { on: (event: string, handler: (arg?: unknown) => void) => { relayerHandlers[event] = handler } } } } vi.mock('@walletconnect/sign-client', () => ({ default: { init: vi.fn(async () => fakeClient) } })) import SignClient from '@walletconnect/sign-client' import { WalletConnectClient, toTelemetryObserver, MAX_REPORTED_RELAY_ERRORS, MAX_REPORTED_RELAY_LIFECYCLE } from './walletconnect-client' const CONFIG = { projectId: 'p' } as WalletConnectConfig // `mockRejectedValueOnce` queues a single rejection, then the base // `async () => fakeClient` implementation resumes — so these tests never leak // into the relay-error suite above. const initMock = vi.mocked(SignClient.init) describe('WalletConnectClient relay-error surfacing', () => { beforeEach(() => { for (const key of Object.keys(relayerHandlers)) delete relayerHandlers[key] }) it('forwards a relayer error to onRelayError', async () => { const errors: unknown[] = [] const client = new WalletConnectClient(CONFIG, { onRelayError: e => errors.push(e) }) await client.getClient() relayerHandlers[RELAYER_EVENTS.error]?.( new Error( 'WebSocket connection closed abnormally with code: 3000 (JWT validation error: JWT Token is not yet valid)' ) ) expect(errors).toHaveLength(1) expect((errors[0] as Error).message).toContain('3000') }) it('dedupes repeated identical relay errors (reconnect storm → one report)', async () => { const errors: unknown[] = [] const client = new WalletConnectClient(CONFIG, { onRelayError: e => errors.push(e) }) await client.getClient() const fire = () => relayerHandlers[RELAYER_EVENTS.error]?.(new Error('same message')) fire() fire() fire() expect(errors).toHaveLength(1) }) it('dedupes dynamic code-3000 JWT validation errors', async () => { const errors: unknown[] = [] const client = new WalletConnectClient(CONFIG, { onRelayError: e => errors.push(e) }) await client.getClient() const fire = (issuedAt: number, relayNow: number) => relayerHandlers[RELAYER_EVENTS.error]?.( new Error( `WebSocket connection closed abnormally with code: 3000 (JWT validation error: JWT Token is not yet valid: basic.iat: ${issuedAt}, now + time_leeway: ${relayNow}, time_leeway: 120)` ) ) fire(1784405170, 1784405120) relayerHandlers[RELAYER_EVENTS.error]?.( new Error( 'WebSocket connection closed abnormally with code: 3000 (JWT validation error: JWT Token is expired: Some(1784227017))' ) ) relayerHandlers[RELAYER_EVENTS.error]?.( new Error( 'WebSocket connection closed abnormally with code: 3000 (JWT validation error: JWT Token is not yet valid: basic.iat: 1784405173, now + time_leeway: 1784405123, time_leeway: 90)' ) ) expect(errors).toHaveLength(1) expect((errors[0] as Error).message).toContain('basic.iat: 1784405170') }) it('swallows a throwing onRelayError sink — must not break the relayer emitter', async () => { const client = new WalletConnectClient(CONFIG, { onRelayError: () => { throw new Error('sink exploded') } }) await client.getClient() // The handler runs inside the WC SDK's own relayer error dispatch — a // propagating throw would prevent the SDK's transportClose from running. expect(() => relayerHandlers[RELAYER_EVENTS.error]?.(new Error('relay down')) ).not.toThrow() }) it('never throws when the relayer emits a value with a throwing toString()', async () => { const errors: unknown[] = [] const client = new WalletConnectClient(CONFIG, { onRelayError: e => errors.push(e) }) await client.getClient() const evil = { toString(): string { throw new Error('poisoned toString') } } // Message derivation runs inside the SDK's relayer dispatch, before the // sink guard — it must be throw-proof too. expect(() => relayerHandlers[RELAYER_EVENTS.error]?.(evil)).not.toThrow() expect(errors).toHaveLength(1) }) it('does not attach a relayer listener when no onRelayError is wired', async () => { const client = new WalletConnectClient(CONFIG) await expect(client.getClient()).resolves.toBeDefined() expect(relayerHandlers[RELAYER_EVENTS.error]).toBeUndefined() }) it('does not emit after the relay-error telemetry cap is reached', async () => { const errors: unknown[] = [] const client = new WalletConnectClient(CONFIG, { onRelayError: e => errors.push(e) }) await client.getClient() const fire = (m: string) => relayerHandlers[RELAYER_EVENTS.error]?.(new Error(m)) // Fill the set to the cap with distinct messages — each reports once. for (let i = 0; i < MAX_REPORTED_RELAY_ERRORS; i++) fire(`err-${i}`) expect(errors).toHaveLength(MAX_REPORTED_RELAY_ERRORS) // Still deduped while in the set. fire('err-0') expect(errors).toHaveLength(MAX_REPORTED_RELAY_ERRORS) // Distinct messages can cycle the bounded dedup cache, but must not bypass // the hard telemetry cap. fire('over-cap') expect(errors).toHaveLength(MAX_REPORTED_RELAY_ERRORS) // A previously evicted key must remain capped too. fire('err-0') expect(errors).toHaveLength(MAX_REPORTED_RELAY_ERRORS) }) it('caps relay-error telemetry when every message is distinct', async () => { const errors: unknown[] = [] let capped = 0 const client = new WalletConnectClient(CONFIG, { onRelayError: e => errors.push(e), onRelayErrorCapped: () => { capped++ } }) await client.getClient() for (let i = 0; i <= MAX_REPORTED_RELAY_ERRORS; i++) { relayerHandlers[RELAYER_EVENTS.error]?.(new Error(`err-${i}`)) } expect(errors).toHaveLength(MAX_REPORTED_RELAY_ERRORS) expect(capped).toBe(1) }) it('emits the cap signal when it is the only relay-error sink', async () => { let capped = 0 const client = new WalletConnectClient(CONFIG, { onRelayErrorCapped: () => { capped++ } }) await client.getClient() for (let i = 0; i <= MAX_REPORTED_RELAY_ERRORS; i++) { relayerHandlers[RELAYER_EVENTS.error]?.(new Error(`err-${i}`)) } expect(capped).toBe(1) }) }) describe('WalletConnectClient relay-lifecycle surfacing', () => { beforeEach(() => { for (const key of Object.keys(relayerHandlers)) delete relayerHandlers[key] }) it('emits disconnect once per outage — ignores WC-core re-emits during the outage', async () => { let drops = 0 const client = new WalletConnectClient(CONFIG, { onRelayDisconnect: () => { drops++ } }) await client.getClient() // WC core re-emits `disconnect` on each failed reconnect attempt; only the // transition INTO disconnected counts. relayerHandlers[RELAYER_EVENTS.disconnect]?.() relayerHandlers[RELAYER_EVENTS.disconnect]?.() relayerHandlers[RELAYER_EVENTS.disconnect]?.() expect(drops).toBe(1) // A reconnect closes the outage; the next disconnect is a NEW outage. relayerHandlers[RELAYER_EVENTS.connect]?.() relayerHandlers[RELAYER_EVENTS.disconnect]?.() expect(drops).toBe(2) }) it('emits reconnect only for a connect that follows a disconnect (not the initial connect)', async () => { let reconnects = 0 const client = new WalletConnectClient(CONFIG, { onRelayReconnect: () => { reconnects++ } }) await client.getClient() // Initial / spurious connect with no prior drop → not a recovery. relayerHandlers[RELAYER_EVENTS.connect]?.() expect(reconnects).toBe(0) // A drop then a connect → exactly one genuine recovery. relayerHandlers[RELAYER_EVENTS.disconnect]?.({}) relayerHandlers[RELAYER_EVENTS.connect]?.() expect(reconnects).toBe(1) // Another connect with no new drop → not counted again. relayerHandlers[RELAYER_EVENTS.connect]?.() expect(reconnects).toBe(1) }) it('does not attach lifecycle listeners when neither sink is wired', async () => { const client = new WalletConnectClient(CONFIG) await client.getClient() expect(relayerHandlers[RELAYER_EVENTS.disconnect]).toBeUndefined() expect(relayerHandlers[RELAYER_EVENTS.connect]).toBeUndefined() }) it('swallows a throwing lifecycle sink — must not break the relayer emitter', async () => { const client = new WalletConnectClient(CONFIG, { onRelayDisconnect: () => { throw new Error('sink exploded') } }) await client.getClient() // Runs inside the WC SDK's relayer disconnect dispatch — a propagating throw // would prevent the SDK's own transport teardown from running. expect(() => relayerHandlers[RELAYER_EVENTS.disconnect]?.({})).not.toThrow() }) it('caps disconnect and reconnect INDEPENDENTLY past the cap (recovery never starved by drops)', async () => { let drops = 0 let recoveries = 0 const client = new WalletConnectClient(CONFIG, { onRelayDisconnect: () => { drops++ }, onRelayReconnect: () => { recoveries++ } }) await client.getClient() // Each outage is a disconnect→connect cycle. Fire well past the cap; each // event type has its OWN budget, so BOTH reach MAX — a drop-heavy flap can // never exhaust a shared pool and silently swallow the paired recovery. for (let i = 0; i < MAX_REPORTED_RELAY_LIFECYCLE + 20; i++) { relayerHandlers[RELAYER_EVENTS.disconnect]?.() relayerHandlers[RELAYER_EVENTS.connect]?.() } expect(drops).toBe(MAX_REPORTED_RELAY_LIFECYCLE) expect(recoveries).toBe(MAX_REPORTED_RELAY_LIFECYCLE) }) it('still reports a recovery after the DISCONNECT budget alone is exhausted', async () => { // Szymon's scenario: a socket that drops many times, then finally recovers. // With a shared budget the recovery could be dropped; with independent // budgets the reconnect must still surface. let drops = 0 let recoveries = 0 const client = new WalletConnectClient(CONFIG, { onRelayDisconnect: () => { drops++ }, onRelayReconnect: () => { recoveries++ } }) await client.getClient() // Exhaust the disconnect budget across many full outages... for (let i = 0; i < MAX_REPORTED_RELAY_LIFECYCLE + 5; i++) { relayerHandlers[RELAYER_EVENTS.disconnect]?.() relayerHandlers[RELAYER_EVENTS.connect]?.() } expect(drops).toBe(MAX_REPORTED_RELAY_LIFECYCLE) // ...the reconnect budget is its own, so recoveries kept surfacing throughout. expect(recoveries).toBe(MAX_REPORTED_RELAY_LIFECYCLE) }) }) describe('WalletConnectClient init-error surfacing', () => { it('forwards a failed SignClient.init to onInitError and rethrows unchanged', async () => { const initErr = new Error('IndexedDB unavailable at init') initMock.mockRejectedValueOnce(initErr) const errors: unknown[] = [] const client = new WalletConnectClient(CONFIG, { onInitError: e => errors.push(e) }) await expect(client.getClient()).rejects.toBe(initErr) expect(errors).toEqual([initErr]) }) it('rethrows the init rejection even when the onInitError sink throws', async () => { const initErr = new Error('init boom') initMock.mockRejectedValueOnce(initErr) const client = new WalletConnectClient(CONFIG, { onInitError: () => { throw new Error('sink exploded') } }) await expect(client.getClient()).rejects.toBe(initErr) }) it('still rejects when no onInitError is wired (behaviour unchanged)', async () => { const initErr = new Error('init failed, no sink') initMock.mockRejectedValueOnce(initErr) const client = new WalletConnectClient(CONFIG) await expect(client.getClient()).rejects.toBe(initErr) }) }) describe('toTelemetryObserver (positional back-compat normalizer)', () => { it('maps a legacy (onRelayError, onInitError) function pair to the observer shape', () => { const onRelayError = () => {} const onInitError = () => {} expect(toTelemetryObserver(onRelayError, onInitError)).toEqual({ onRelayError, onInitError }) }) it('maps a lone legacy onRelayError with no init sink (onInitError key omitted)', () => { const onRelayError = () => {} expect(toTelemetryObserver(onRelayError)).toEqual({ onRelayError }) expect('onInitError' in toTelemetryObserver(onRelayError)).toBe(false) }) it('keeps a legacy init-only call — (undefined, onInitError) — without dropping the sink', () => { // 0.8.x callers could skip onRelayError: `new WalletConnectClient(config, undefined, onInitError)`. const onInitError = () => {} expect(toTelemetryObserver(undefined, onInitError)).toEqual({ onInitError }) expect('onRelayError' in toTelemetryObserver(undefined, onInitError)).toBe( false ) }) it('passes a new-form observer object through untouched', () => { const observer = { onRelayDisconnect: () => {}, onRelayReconnect: () => {} } expect(toTelemetryObserver(observer)).toBe(observer) }) it('returns an empty observer for an omitted argument', () => { expect(toTelemetryObserver(undefined)).toEqual({}) }) }) describe('WalletConnectClient legacy positional constructor (0.8.x back-compat)', () => { beforeEach(() => { for (const key of Object.keys(relayerHandlers)) delete relayerHandlers[key] }) it('forwards a relayer error when onRelayError is passed positionally', async () => { const errors: unknown[] = [] // Legacy 0.8.x call site: (config, onRelayError) — a bare function, not an observer. const client = new WalletConnectClient(CONFIG, e => errors.push(e)) await client.getClient() relayerHandlers[RELAYER_EVENTS.error]?.(new Error('code: 3000')) expect(errors).toHaveLength(1) expect((errors[0] as Error).message).toContain('3000') }) it('forwards a failed init when onInitError is passed as the 3rd positional arg', async () => { const initErr = new Error('IndexedDB unavailable at init') initMock.mockRejectedValueOnce(initErr) const errors: unknown[] = [] // Legacy 0.8.x call site: (config, onRelayError, onInitError). const client = new WalletConnectClient( CONFIG, () => {}, e => errors.push(e) ) await expect(client.getClient()).rejects.toBe(initErr) expect(errors).toEqual([initErr]) }) it('does NOT attach lifecycle listeners for the legacy form (it predates them)', async () => { // The legacy signature never carried onRelayDisconnect/onRelayReconnect, so // the lifecycle listeners must stay unattached — no phantom drop/reconnect. const client = new WalletConnectClient(CONFIG, () => {}) await client.getClient() expect(relayerHandlers[RELAYER_EVENTS.error]).toBeDefined() expect(relayerHandlers[RELAYER_EVENTS.disconnect]).toBeUndefined() expect(relayerHandlers[RELAYER_EVENTS.connect]).toBeUndefined() }) }) describe('WalletConnectTelemetryObserver wiring (single options object)', () => { beforeEach(() => { for (const key of Object.keys(relayerHandlers)) delete relayerHandlers[key] }) it('routes each relayer signal to its own sink with no cross-wiring', async () => { // One observer wiring all three relayer sinks — each must fire only for its // own event. Regression guard against the object mis-mapping a field. const seen: string[] = [] const client = new WalletConnectClient(CONFIG, { onRelayError: () => seen.push('error'), onRelayDisconnect: () => seen.push('disconnect'), onRelayReconnect: () => seen.push('reconnect') }) await client.getClient() relayerHandlers[RELAYER_EVENTS.error]?.(new Error('boom')) relayerHandlers[RELAYER_EVENTS.disconnect]?.() relayerHandlers[RELAYER_EVENTS.connect]?.() // recovery after the drop above expect(seen).toEqual(['error', 'disconnect', 'reconnect']) }) it('attaches only the listeners for the sinks that are provided', async () => { // Error sink only → the error listener attaches, lifecycle listeners do not. const client = new WalletConnectClient(CONFIG, { onRelayError: () => {} }) await client.getClient() expect(relayerHandlers[RELAYER_EVENTS.error]).toBeDefined() expect(relayerHandlers[RELAYER_EVENTS.disconnect]).toBeUndefined() expect(relayerHandlers[RELAYER_EVENTS.connect]).toBeUndefined() }) it('attaches nothing for an empty observer (default) — behaviour unchanged', async () => { const client = new WalletConnectClient(CONFIG, {}) await client.getClient() expect(relayerHandlers[RELAYER_EVENTS.error]).toBeUndefined() expect(relayerHandlers[RELAYER_EVENTS.disconnect]).toBeUndefined() expect(relayerHandlers[RELAYER_EVENTS.connect]).toBeUndefined() }) })