From 10095233b1cced77da94dab5e63d9f056f26715a Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 10:46:59 -1000 Subject: [PATCH 01/22] feat: init sentry implementation --- .../controllers/perps/infrastructure.test.ts | 400 +++++++++++++++++- .../controllers/perps/infrastructure.ts | 70 ++- ui/components/app/perps/perps-view.tsx | 5 + .../perps/utils/translate-perps-error.test.ts | 278 ++++++++++++ .../app/perps/utils/translate-perps-error.ts | 226 ++++++++++ ui/hooks/perps/index.ts | 2 + .../usePerpsLifecycleBreadcrumbs.test.ts | 133 ++++++ .../perps/usePerpsLifecycleBreadcrumbs.ts | 56 +++ ui/hooks/perps/usePerpsMeasurement.test.ts | 132 ++++++ ui/hooks/perps/usePerpsMeasurement.ts | 32 ++ ui/pages/perps/perps-market-detail-page.tsx | 3 + 11 files changed, 1326 insertions(+), 11 deletions(-) create mode 100644 ui/components/app/perps/utils/translate-perps-error.test.ts create mode 100644 ui/components/app/perps/utils/translate-perps-error.ts create mode 100644 ui/hooks/perps/usePerpsLifecycleBreadcrumbs.test.ts create mode 100644 ui/hooks/perps/usePerpsLifecycleBreadcrumbs.ts create mode 100644 ui/hooks/perps/usePerpsMeasurement.test.ts create mode 100644 ui/hooks/perps/usePerpsMeasurement.ts diff --git a/app/scripts/controllers/perps/infrastructure.test.ts b/app/scripts/controllers/perps/infrastructure.test.ts index 6add2628b7cb..5c4b27b8b494 100644 --- a/app/scripts/controllers/perps/infrastructure.test.ts +++ b/app/scripts/controllers/perps/infrastructure.test.ts @@ -1,6 +1,16 @@ import { createPerpsInfrastructure } from './infrastructure'; +const mockCaptureException = jest.fn(); +jest.mock('../../../../shared/lib/sentry', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})); + describe('createPerpsInfrastructure', () => { + afterEach(() => { + jest.clearAllMocks(); + delete (globalThis as Record).sentry; + }); + it('returns a valid PerpsPlatformDependencies object', () => { const infrastructure = createPerpsInfrastructure(); @@ -16,8 +26,392 @@ describe('createPerpsInfrastructure', () => { expect(infrastructure.rewards).toBeDefined(); }); + describe('logger', () => { + describe('when sentry.withScope is not available', () => { + it('falls back to captureException without scope', () => { + const { logger } = createPerpsInfrastructure(); + const error = new Error('test error'); + + logger.error(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error); + }); + + it('does not throw when options are provided but withScope is unavailable', () => { + const { logger } = createPerpsInfrastructure(); + const error = new Error('test error'); + + expect(() => + logger.error(error, { + tags: { provider: 'hyperliquid' }, + context: { + name: 'PerpsController', + data: { method: 'placeOrder' }, + }, + extras: { orderId: '123' }, + }), + ).not.toThrow(); + }); + }); + + describe('when sentry.withScope is available', () => { + it('always sets the feature:perps tag', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + logger.error(new Error('test')); + + expect(mockScope.setTag).toHaveBeenCalledWith('feature', 'perps'); + }); + + it('forwards errors to captureException inside the scope', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + const error = new Error('test error'); + logger.error(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error); + }); + + it('sets extra tags from options on the scope', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + logger.error(new Error('test'), { + tags: { provider: 'hyperliquid', network: 'mainnet' }, + }); + + expect(mockScope.setTag).toHaveBeenCalledWith( + 'provider', + 'hyperliquid', + ); + expect(mockScope.setTag).toHaveBeenCalledWith('network', 'mainnet'); + }); + + it('converts numeric tag values to strings', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + logger.error(new Error('test'), { tags: { retryCount: 3 } }); + + expect(mockScope.setTag).toHaveBeenCalledWith('retryCount', '3'); + }); + + it('sets Sentry context from options', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + logger.error(new Error('test'), { + context: { + name: 'PerpsController', + data: { method: 'placeOrder', orderId: 'abc123' }, + }, + }); + + expect(mockScope.setContext).toHaveBeenCalledWith('PerpsController', { + method: 'placeOrder', + orderId: 'abc123', + }); + }); + + it('sets Sentry extras from options', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + logger.error(new Error('test'), { + extras: { requestPayload: '{"coin":"ETH"}' }, + }); + + expect(mockScope.setExtras).toHaveBeenCalledWith({ + requestPayload: '{"coin":"ETH"}', + }); + }); + + it('works correctly when options are omitted', () => { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + + const { logger } = createPerpsInfrastructure(); + const error = new Error('bare error'); + logger.error(error); + + expect(mockScope.setTag).toHaveBeenCalledWith('feature', 'perps'); + expect(mockScope.setContext).not.toHaveBeenCalled(); + expect(mockScope.setExtras).not.toHaveBeenCalled(); + expect(mockCaptureException).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('metrics', () => { + it('reports metrics as disabled', () => { + const { metrics } = createPerpsInfrastructure(); + + expect(metrics.isEnabled()).toBe(false); + }); + + it('does not throw when tracking an event', () => { + const { metrics } = createPerpsInfrastructure(); + + expect(() => + metrics.trackPerpsEvent('test_event' as never, {} as never), + ).not.toThrow(); + }); + }); + + describe('performance', () => { + it('returns a numeric timestamp', () => { + const { performance: perf } = createPerpsInfrastructure(); + + expect(typeof perf.now()).toBe('number'); + }); + }); + + describe('tracer', () => { + describe('when sentry is not available', () => { + it('does not throw on trace', () => { + const { tracer } = createPerpsInfrastructure(); + + expect(() => + tracer.trace({ + name: 'Perps Place Order' as never, + id: '1', + op: 'perps.order', + }), + ).not.toThrow(); + }); + + it('does not throw on endTrace', () => { + const { tracer } = createPerpsInfrastructure(); + + expect(() => + tracer.endTrace({ name: 'Perps Place Order' as never, id: '1' }), + ).not.toThrow(); + }); + + it('does not throw on setMeasurement', () => { + const { tracer } = createPerpsInfrastructure(); + + expect(() => + tracer.setMeasurement('test', 100, 'millisecond'), + ).not.toThrow(); + }); + + }); + + describe('when sentry is available', () => { + it('calls startSpanManual on trace', () => { + const mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; + const startSpanManual = jest.fn((_opts, cb) => cb(mockSpan)); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'abc', + op: 'perps.order', + data: { coin: 'ETH' }, + }); + + expect(startSpanManual).toHaveBeenCalledWith( + { + name: 'Perps Place Order', + op: 'perps.order', + attributes: { coin: 'ETH' }, + }, + expect.any(Function), + ); + }); + + it('merges tags and data into span attributes', () => { + const mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; + const startSpanManual = jest.fn((_opts, cb) => cb(mockSpan)); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'abc', + op: 'perps.order', + tags: { network: 'arbitrum' }, + data: { coin: 'ETH' }, + }); + + expect(startSpanManual).toHaveBeenCalledWith( + { + name: 'Perps Place Order', + op: 'perps.order', + attributes: { network: 'arbitrum', coin: 'ETH' }, + }, + expect.any(Function), + ); + }); + + it('ends the span on endTrace', () => { + const mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; + const startSpanManual = jest.fn((_opts, cb) => cb(mockSpan)); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'abc', + op: 'perps.order', + }); + + tracer.endTrace({ name: 'Perps Place Order' as never, id: 'abc' }); + + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('does nothing on endTrace for unknown span', () => { + (globalThis as Record).sentry = { + startSpanManual: jest.fn(), + }; + const { tracer } = createPerpsInfrastructure(); + + expect(() => + tracer.endTrace({ name: 'Perps Place Order' as never, id: 'nope' }), + ).not.toThrow(); + }); + + it('sets attributes from data before ending the span', () => { + const mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; + const startSpanManual = jest.fn((_opts, cb) => cb(mockSpan)); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'abc', + op: 'perps.order', + }); + + tracer.endTrace({ + name: 'Perps Place Order' as never, + id: 'abc', + data: { result: 'success', latency: 42 }, + }); + + expect(mockSpan.setAttribute).toHaveBeenCalledWith('result', 'success'); + expect(mockSpan.setAttribute).toHaveBeenCalledWith('latency', 42); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('removes the span after endTrace', () => { + const mockSpan = { setAttribute: jest.fn(), end: jest.fn() }; + const startSpanManual = jest.fn((_opts, cb) => cb(mockSpan)); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'abc', + op: 'perps.order', + }); + tracer.endTrace({ name: 'Perps Place Order' as never, id: 'abc' }); + + // Second endTrace is a no-op — span.end not called again + tracer.endTrace({ name: 'Perps Place Order' as never, id: 'abc' }); + + expect(mockSpan.end).toHaveBeenCalledTimes(1); + }); + + it('calls setMeasurement on sentry', () => { + const setMeasurement = jest.fn(); + (globalThis as Record).sentry = { setMeasurement }; + + const { tracer } = createPerpsInfrastructure(); + tracer.setMeasurement('perps.latency', 42, 'millisecond'); + + expect(setMeasurement).toHaveBeenCalledWith( + 'perps.latency', + 42, + 'millisecond', + ); + }); + + }); + }); + + describe('streamManager', () => { + it('does not throw on pauseChannel', () => { + const { streamManager } = createPerpsInfrastructure(); + + expect(() => streamManager.pauseChannel('test')).not.toThrow(); + }); + + it('does not throw on resumeChannel', () => { + const { streamManager } = createPerpsInfrastructure(); + + expect(() => streamManager.resumeChannel('test')).not.toThrow(); + }); + + it('does not throw on clearAllChannels', () => { + const { streamManager } = createPerpsInfrastructure(); + + expect(() => streamManager.clearAllChannels()).not.toThrow(); + }); + }); + describe('featureFlags', () => { - it('validateVersionGated returns true as default stub', () => { + it('validates a version-gated flag', () => { const infrastructure = createPerpsInfrastructure(); const result = infrastructure.featureFlags.validateVersionGated({ enabled: true, @@ -60,7 +454,7 @@ describe('createPerpsInfrastructure', () => { }); describe('cacheInvalidator', () => { - it('invalidate does not throw', () => { + it('does not throw on invalidate', () => { const infrastructure = createPerpsInfrastructure(); expect(() => infrastructure.cacheInvalidator.invalidate({ @@ -69,7 +463,7 @@ describe('createPerpsInfrastructure', () => { ).not.toThrow(); }); - it('invalidateAll does not throw', () => { + it('does not throw on invalidateAll', () => { const infrastructure = createPerpsInfrastructure(); expect(() => infrastructure.cacheInvalidator.invalidateAll(), diff --git a/app/scripts/controllers/perps/infrastructure.ts b/app/scripts/controllers/perps/infrastructure.ts index b57b55ed278d..7c2a46eba039 100644 --- a/app/scripts/controllers/perps/infrastructure.ts +++ b/app/scripts/controllers/perps/infrastructure.ts @@ -6,6 +6,7 @@ */ import { createProjectLogger } from '@metamask/utils'; +import type * as Sentry from '@sentry/browser'; import type { PerpsPlatformDependencies, PerpsCacheInvalidator, @@ -29,8 +30,27 @@ const debugLog = createProjectLogger('perps'); function createLogger(): PerpsLogger { return { - error: (error) => { - captureException(error); + error: (error, options) => { + const withScope = globalThis.sentry?.withScope; + if (!withScope) { + captureException(error); + return; + } + withScope((scope: Sentry.Scope) => { + scope.setTag('feature', 'perps'); + if (options?.tags) { + for (const [k, v] of Object.entries(options.tags)) { + scope.setTag(k, String(v)); + } + } + if (options?.context) { + scope.setContext(options.context.name, options.context.data); + } + if (options?.extras) { + scope.setExtras(options.extras); + } + captureException(error); + }); }, }; } @@ -58,25 +78,59 @@ function createPerformance(): PerpsPerformance { } function createTracer(): PerpsTracer { + const pendingSpans = new Map< + string, + { + setAttribute: (key: string, value: PerpsTraceValue) => void; + end: () => void; + } + >(); + return { - trace: (_params: { + trace: (params: { name: PerpsTraceName; id: string; op: string; tags?: Record; data?: Record; }) => { - // TODO: Integrate with Sentry tracing when ready + const startSpanManual = globalThis.sentry?.startSpanManual; + if (!startSpanManual) { + return; + } + startSpanManual( + { + name: params.name, + op: params.op, + attributes: { ...params.tags, ...params.data }, + }, + (span: { + setAttribute: (key: string, value: PerpsTraceValue) => void; + end: () => void; + }) => { + pendingSpans.set(`${params.name}:${params.id}`, span); + }, + ); }, - endTrace: (_params: { + endTrace: (params: { name: PerpsTraceName; id: string; data?: Record; }) => { - // TODO: End Sentry span + const key = `${params.name}:${params.id}`; + const pending = pendingSpans.get(key); + if (pending) { + if (params.data) { + for (const [attrKey, attrValue] of Object.entries(params.data)) { + pending.setAttribute(attrKey, attrValue); + } + } + pending.end(); + pendingSpans.delete(key); + } }, - setMeasurement: (_name: string, _value: number, _unit: string) => { - // TODO: Set Sentry measurement + setMeasurement: (name: string, value: number, unit: string) => { + globalThis.sentry?.setMeasurement?.(name, value, unit); }, }; } diff --git a/ui/components/app/perps/perps-view.tsx b/ui/components/app/perps/perps-view.tsx index 6240cff00689..cc5beab55c3d 100644 --- a/ui/components/app/perps/perps-view.tsx +++ b/ui/components/app/perps/perps-view.tsx @@ -27,6 +27,8 @@ import { setTutorialModalOpen, } from '../../../ducks/perps'; +import { usePerpsMeasurement } from '../../../hooks/perps/usePerpsMeasurement'; +import { usePerpsLifecycleBreadcrumbs } from '../../../hooks/perps/usePerpsLifecycleBreadcrumbs'; import { usePerpsDepositConfirmation } from './hooks/usePerpsDepositConfirmation'; import { usePerpsWithdrawNavigation } from './hooks/usePerpsWithdrawNavigation'; import { PerpsBalanceDropdown } from './perps-balance-dropdown'; @@ -181,6 +183,9 @@ export const PerpsView: React.FC = () => { const hasPositions = positions.length > 0; const isLoading = positionsLoading || ordersLoading || marketsLoading; + usePerpsMeasurement('PerpsTabLoaded', !isLoading); + usePerpsLifecycleBreadcrumbs(); + // Auto-open tutorial modal the first time a user enters the perps domain. // Guards on both the backend isFirstTimeUser flag (stable once propagated) and // the local tutorialCompleted flag so that a skip/complete before the backend diff --git a/ui/components/app/perps/utils/translate-perps-error.test.ts b/ui/components/app/perps/utils/translate-perps-error.test.ts new file mode 100644 index 000000000000..853ae9c72913 --- /dev/null +++ b/ui/components/app/perps/utils/translate-perps-error.test.ts @@ -0,0 +1,278 @@ +import { PERPS_ERROR_CODES } from '@metamask/perps-controller'; +import { + ERROR_CODE_TO_I18N_KEY, + API_ERROR_PATTERNS, + translatePerpsError, + handlePerpsError, +} from './translate-perps-error'; + +jest.mock('@metamask/perps-controller', () => ({ + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); + +const mockT = (key: string) => `[${key}]`; + +describe('ERROR_CODE_TO_I18N_KEY', () => { + it('maps every PERPS_ERROR_CODES value to an i18n key', () => { + for (const code of Object.values(PERPS_ERROR_CODES)) { + expect(ERROR_CODE_TO_I18N_KEY).toHaveProperty(code); + expect(typeof ERROR_CODE_TO_I18N_KEY[code]).toBe('string'); + } + }); + + it('maps WITHDRAW_FAILED to perpsWithdrawFailed', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.WITHDRAW_FAILED]).toBe( + 'perpsWithdrawFailed', + ); + }); + + it('maps WITHDRAW_INSUFFICIENT_BALANCE to perpsWithdrawInsufficient', () => { + expect( + ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.WITHDRAW_INSUFFICIENT_BALANCE], + ).toBe('perpsWithdrawInsufficient'); + }); + + it('maps NO_ACCOUNT_SELECTED to perpsWithdrawNoAccount', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED]).toBe( + 'perpsWithdrawNoAccount', + ); + }); + + it('maps ORDER_REJECTED to perpsOrderRejected', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.ORDER_REJECTED]).toBe( + 'perpsOrderRejected', + ); + }); + + it('maps SLIPPAGE_EXCEEDED to perpsSlippageExceeded', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.SLIPPAGE_EXCEEDED]).toBe( + 'perpsSlippageExceeded', + ); + }); + + it('maps RATE_LIMIT_EXCEEDED to perpsRateLimitExceeded', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.RATE_LIMIT_EXCEEDED]).toBe( + 'perpsRateLimitExceeded', + ); + }); + + it('maps NETWORK_ERROR to perpsNetworkError', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.NETWORK_ERROR]).toBe( + 'perpsNetworkError', + ); + }); + + it('maps BATCH_CANCEL_FAILED to perpsBatchCancelFailed', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.BATCH_CANCEL_FAILED]).toBe( + 'perpsBatchCancelFailed', + ); + }); + + it('maps BATCH_CLOSE_FAILED to perpsBatchCloseFailed', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.BATCH_CLOSE_FAILED]).toBe( + 'perpsBatchCloseFailed', + ); + }); + + it('maps INSUFFICIENT_MARGIN to perpsInsufficientMargin', () => { + expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.INSUFFICIENT_MARGIN]).toBe( + 'perpsInsufficientMargin', + ); + }); +}); + +describe('API_ERROR_PATTERNS', () => { + it('contains at least one pattern entry', () => { + expect(API_ERROR_PATTERNS.length).toBeGreaterThan(0); + }); + + it('each entry has a RegExp pattern and a valid PerpsErrorCode', () => { + for (const { pattern, code } of API_ERROR_PATTERNS) { + expect(pattern).toBeInstanceOf(RegExp); + expect(Object.values(PERPS_ERROR_CODES)).toContain(code); + } + }); + + it('matches "insufficient margin" to INSUFFICIENT_MARGIN', () => { + const match = API_ERROR_PATTERNS.find(({ pattern }) => + pattern.test('insufficient margin for this trade'), + ); + expect(match?.code).toBe(PERPS_ERROR_CODES.INSUFFICIENT_MARGIN); + }); + + it('matches "order rejected" to ORDER_REJECTED', () => { + const match = API_ERROR_PATTERNS.find(({ pattern }) => + pattern.test('Order rejected by exchange'), + ); + expect(match?.code).toBe(PERPS_ERROR_CODES.ORDER_REJECTED); + }); + + it('matches "rate limit" to RATE_LIMIT_EXCEEDED', () => { + const match = API_ERROR_PATTERNS.find(({ pattern }) => + pattern.test('rate limit exceeded'), + ); + expect(match?.code).toBe(PERPS_ERROR_CODES.RATE_LIMIT_EXCEEDED); + }); + + it('matches "slippage" to SLIPPAGE_EXCEEDED', () => { + const match = API_ERROR_PATTERNS.find(({ pattern }) => + pattern.test('slippage tolerance exceeded'), + ); + expect(match?.code).toBe(PERPS_ERROR_CODES.SLIPPAGE_EXCEEDED); + }); + + it('matches "ioc cancel" to IOC_CANCEL', () => { + const match = API_ERROR_PATTERNS.find(({ pattern }) => + pattern.test('ioc cancel'), + ); + expect(match?.code).toBe(PERPS_ERROR_CODES.IOC_CANCEL); + }); + + it('matches case-insensitively', () => { + const match = API_ERROR_PATTERNS.find(({ pattern }) => + pattern.test('INSUFFICIENT MARGIN'), + ); + expect(match?.code).toBe(PERPS_ERROR_CODES.INSUFFICIENT_MARGIN); + }); +}); + +describe('translatePerpsError', () => { + it('returns null for a plain non-Error value', () => { + expect(translatePerpsError('some string', mockT)).toBeNull(); + expect(translatePerpsError(42, mockT)).toBeNull(); + expect(translatePerpsError(null, mockT)).toBeNull(); + }); + + it('returns null for an Error with no code and no pattern match', () => { + const error = new Error('completely unknown failure'); + expect(translatePerpsError(error, mockT)).toBeNull(); + }); + + it('uses ERROR_CODE_TO_I18N_KEY when error has a known code', () => { + const error = Object.assign(new Error('withdraw failed'), { + code: PERPS_ERROR_CODES.WITHDRAW_FAILED, + }); + expect(translatePerpsError(error, mockT)).toBe('[perpsWithdrawFailed]'); + }); + + it('falls back to pattern matching when no code property is present', () => { + const error = new Error('Order rejected by the exchange'); + expect(translatePerpsError(error, mockT)).toBe('[perpsOrderRejected]'); + }); + + it('falls back to pattern matching when code is unknown', () => { + const error = Object.assign( + new Error('insufficient margin on this trade'), + { + code: 'TOTALLY_UNKNOWN_CODE', + }, + ); + expect(translatePerpsError(error, mockT)).toBe('[perpsInsufficientMargin]'); + }); + + it('returns the translated string for RATE_LIMIT_EXCEEDED code', () => { + const error = Object.assign(new Error('too many requests'), { + code: PERPS_ERROR_CODES.RATE_LIMIT_EXCEEDED, + }); + expect(translatePerpsError(error, mockT)).toBe('[perpsRateLimitExceeded]'); + }); + + it('returns the translated string for SLIPPAGE_EXCEEDED code', () => { + const error = Object.assign(new Error('slippage exceeded'), { + code: PERPS_ERROR_CODES.SLIPPAGE_EXCEEDED, + }); + expect(translatePerpsError(error, mockT)).toBe('[perpsSlippageExceeded]'); + }); + + it('returns translated string for BATCH_CANCEL_FAILED', () => { + const error = Object.assign(new Error('batch cancel failed'), { + code: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, + }); + expect(translatePerpsError(error, mockT)).toBe('[perpsBatchCancelFailed]'); + }); +}); + +describe('handlePerpsError', () => { + it('returns translated message when error has a known code', () => { + const error = Object.assign(new Error('withdraw'), { + code: PERPS_ERROR_CODES.WITHDRAW_FAILED, + }); + expect(handlePerpsError(error, mockT)).toBe('[perpsWithdrawFailed]'); + }); + + it('falls back to somethingWentWrong for unknown errors', () => { + expect(handlePerpsError(new Error('totally unknown'), mockT)).toBe( + '[somethingWentWrong]', + ); + }); + + it('falls back to somethingWentWrong for non-Error values', () => { + expect(handlePerpsError(null, mockT)).toBe('[somethingWentWrong]'); + expect(handlePerpsError(undefined, mockT)).toBe('[somethingWentWrong]'); + expect(handlePerpsError('raw string error', mockT)).toBe( + '[somethingWentWrong]', + ); + }); + + it('returns translated message when pattern match succeeds', () => { + const error = new Error('connection timed out waiting for server'); + expect(handlePerpsError(error, mockT)).toBe('[perpsConnectionTimeout]'); + }); +}); diff --git a/ui/components/app/perps/utils/translate-perps-error.ts b/ui/components/app/perps/utils/translate-perps-error.ts new file mode 100644 index 000000000000..1c1e94c9ccaa --- /dev/null +++ b/ui/components/app/perps/utils/translate-perps-error.ts @@ -0,0 +1,226 @@ +import { PERPS_ERROR_CODES } from '@metamask/perps-controller'; +import type { PerpsErrorCode } from '@metamask/perps-controller'; + +/** + * Maps PerpsErrorCode values to extension i18n message keys. + * Keys that fall through to `somethingWentWrong` do not have + * user-meaningful distinctions beyond a generic failure message. + */ +export const ERROR_CODE_TO_I18N_KEY: Record = { + // Client lifecycle + [PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.CLIENT_REINITIALIZING]: 'somethingWentWrong', + + // Provider / token + [PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE]: 'somethingWentWrong', + [PERPS_ERROR_CODES.TOKEN_NOT_SUPPORTED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.BRIDGE_CONTRACT_NOT_FOUND]: 'somethingWentWrong', + + // Data fetch failures + [PERPS_ERROR_CODES.WITHDRAW_FAILED]: 'perpsWithdrawFailed', + [PERPS_ERROR_CODES.POSITIONS_FAILED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.ACCOUNT_STATE_FAILED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.MARKETS_FAILED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.UNKNOWN_ERROR]: 'somethingWentWrong', + + // Order errors + [PERPS_ERROR_CODES.ORDER_LEVERAGE_REDUCTION_FAILED]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.IOC_CANCEL]: 'perpsOrderFailed', + + // Connection + [PERPS_ERROR_CODES.CONNECTION_TIMEOUT]: 'perpsConnectionTimeout', + + // Withdraw validation + [PERPS_ERROR_CODES.WITHDRAW_ASSET_ID_REQUIRED]: 'perpsWithdrawInvalidAmount', + [PERPS_ERROR_CODES.WITHDRAW_AMOUNT_REQUIRED]: 'perpsWithdrawInvalidAmount', + [PERPS_ERROR_CODES.WITHDRAW_AMOUNT_POSITIVE]: 'perpsWithdrawInvalidAmount', + [PERPS_ERROR_CODES.WITHDRAW_INVALID_DESTINATION]: + 'perpsWithdrawInvalidAmount', + [PERPS_ERROR_CODES.WITHDRAW_ASSET_NOT_SUPPORTED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.WITHDRAW_INSUFFICIENT_BALANCE]: + 'perpsWithdrawInsufficient', + + // Deposit validation + [PERPS_ERROR_CODES.DEPOSIT_ASSET_ID_REQUIRED]: 'perpsDepositFailed', + [PERPS_ERROR_CODES.DEPOSIT_AMOUNT_REQUIRED]: 'perpsDepositFailed', + [PERPS_ERROR_CODES.DEPOSIT_AMOUNT_POSITIVE]: 'perpsDepositFailed', + [PERPS_ERROR_CODES.DEPOSIT_MINIMUM_AMOUNT]: 'perpsDepositFailed', + + // Order validation + [PERPS_ERROR_CODES.ORDER_COIN_REQUIRED]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_PRICE_POSITIVE]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_UNKNOWN_COIN]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_SIZE_MIN]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_LEVERAGE_BELOW_POSITION]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.ORDER_MAX_VALUE_EXCEEDED]: 'perpsOrderFailed', + + // HyperLiquid client errors + [PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE]: 'somethingWentWrong', + [PERPS_ERROR_CODES.INFO_CLIENT_NOT_AVAILABLE]: 'somethingWentWrong', + [PERPS_ERROR_CODES.SUBSCRIPTION_CLIENT_NOT_AVAILABLE]: 'somethingWentWrong', + + // Wallet / account + [PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED]: 'perpsWithdrawNoAccount', + // KEYRING_LOCKED is handled silently and never surfaced to the user + [PERPS_ERROR_CODES.KEYRING_LOCKED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT]: 'perpsWithdrawInvalidAmount', + + // Transfer / swap + [PERPS_ERROR_CODES.TRANSFER_FAILED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.SWAP_FAILED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.SPOT_PAIR_NOT_FOUND]: 'somethingWentWrong', + [PERPS_ERROR_CODES.PRICE_UNAVAILABLE]: 'somethingWentWrong', + + // Batch operations + [PERPS_ERROR_CODES.BATCH_CANCEL_FAILED]: 'perpsBatchCancelFailed', + [PERPS_ERROR_CODES.BATCH_CLOSE_FAILED]: 'perpsBatchCloseFailed', + + // Position / margin + [PERPS_ERROR_CODES.INSUFFICIENT_MARGIN]: 'perpsInsufficientMargin', + [PERPS_ERROR_CODES.INSUFFICIENT_BALANCE]: 'perpsWithdrawInsufficient', + [PERPS_ERROR_CODES.REDUCE_ONLY_VIOLATION]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.POSITION_WOULD_FLIP]: 'perpsOrderFailed', + [PERPS_ERROR_CODES.MARGIN_ADJUSTMENT_FAILED]: 'somethingWentWrong', + [PERPS_ERROR_CODES.TPSL_UPDATE_FAILED]: 'somethingWentWrong', + + // Order execution + [PERPS_ERROR_CODES.ORDER_REJECTED]: 'perpsOrderRejected', + [PERPS_ERROR_CODES.SLIPPAGE_EXCEEDED]: 'perpsSlippageExceeded', + [PERPS_ERROR_CODES.RATE_LIMIT_EXCEEDED]: 'perpsRateLimitExceeded', + + // Network / service + [PERPS_ERROR_CODES.SERVICE_UNAVAILABLE]: 'perpsServiceUnavailable', + [PERPS_ERROR_CODES.NETWORK_ERROR]: 'perpsNetworkError', +}; + +/** + * Regex patterns that match raw HyperLiquid API error strings to a PerpsErrorCode. + * These are protocol-specific (not platform-specific) and should stay in sync with mobile. + * Listed in priority order — first match wins. + */ +export const API_ERROR_PATTERNS: { + pattern: RegExp; + code: PerpsErrorCode; +}[] = [ + // Order execution + { + pattern: /insufficient margin/iu, + code: PERPS_ERROR_CODES.INSUFFICIENT_MARGIN, + }, + { + pattern: /insufficient balance/iu, + code: PERPS_ERROR_CODES.INSUFFICIENT_BALANCE, + }, + { pattern: /order rejected/iu, code: PERPS_ERROR_CODES.ORDER_REJECTED }, + { pattern: /ioc cancel/iu, code: PERPS_ERROR_CODES.IOC_CANCEL }, + { pattern: /reduce only/iu, code: PERPS_ERROR_CODES.REDUCE_ONLY_VIOLATION }, + { pattern: /would flip/iu, code: PERPS_ERROR_CODES.POSITION_WOULD_FLIP }, + { pattern: /slippage/iu, code: PERPS_ERROR_CODES.SLIPPAGE_EXCEEDED }, + // Rate limiting + { pattern: /rate limit/iu, code: PERPS_ERROR_CODES.RATE_LIMIT_EXCEEDED }, + { + pattern: /too many requests/iu, + code: PERPS_ERROR_CODES.RATE_LIMIT_EXCEEDED, + }, + // Leverage + { + pattern: /leverage.*reduction/iu, + code: PERPS_ERROR_CODES.ORDER_LEVERAGE_REDUCTION_FAILED, + }, + { + pattern: /leverage.*below.*position/iu, + code: PERPS_ERROR_CODES.ORDER_LEVERAGE_BELOW_POSITION, + }, + { + pattern: /invalid leverage/iu, + code: PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID, + }, + // Size / notional + { pattern: /min.*notional/iu, code: PERPS_ERROR_CODES.ORDER_SIZE_MIN }, + { + pattern: /max.*value.*exceeded/iu, + code: PERPS_ERROR_CODES.ORDER_MAX_VALUE_EXCEEDED, + }, + // Service / network + { + pattern: /service.*unavailable/iu, + code: PERPS_ERROR_CODES.SERVICE_UNAVAILABLE, + }, + { + pattern: /connection.*timed? out/iu, + code: PERPS_ERROR_CODES.CONNECTION_TIMEOUT, + }, + { pattern: /network.*error/iu, code: PERPS_ERROR_CODES.NETWORK_ERROR }, + // Unknown coin + { + pattern: /unknown.*coin|asset.*not.*found/iu, + code: PERPS_ERROR_CODES.ORDER_UNKNOWN_COIN, + }, +]; + +/** + * Translate a Perps error to a user-facing string using the extension i18n system. + * + * Resolution order: + * 1. If the error has a `code` property matching a known PerpsErrorCode, use + * `ERROR_CODE_TO_I18N_KEY` to look up the message key. + * 2. If the error message matches an API error pattern, use the mapped code's key. + * 3. Fall back to `null` (caller should show a generic fallback). + * + * @param error - The unknown thrown value. + * @param t - The extension i18n translation function from `useI18nContext()`. + * @returns A translated user-facing string, or `null` if no specific translation found. + */ +export function translatePerpsError( + error: unknown, + t: (key: string) => string, +): string | null { + const errorObj = error instanceof Error ? error : null; + const errorCode = + errorObj && + 'code' in errorObj && + typeof (errorObj as { code?: unknown }).code === 'string' + ? ((errorObj as { code: string }).code as PerpsErrorCode) + : null; + + // 1. Code-first lookup + if (errorCode && errorCode in ERROR_CODE_TO_I18N_KEY) { + const i18nKey = ERROR_CODE_TO_I18N_KEY[errorCode]; + return t(i18nKey); + } + + // 2. Pattern match against raw error message + const message = errorObj?.message ?? ''; + if (message) { + for (const { pattern, code } of API_ERROR_PATTERNS) { + if (pattern.test(message)) { + const i18nKey = ERROR_CODE_TO_I18N_KEY[code]; + return t(i18nKey); + } + } + } + + // 3. No match + return null; +} + +/** + * Context-aware error handler that always returns a user-facing string. + * + * If `translatePerpsError` finds a specific translation it returns that; + * otherwise it falls back to `somethingWentWrong`. + * + * @param error - The unknown thrown value. + * @param t - The extension i18n translation function from `useI18nContext()`. + * @returns A translated user-facing error string. + */ +export function handlePerpsError( + error: unknown, + t: (key: string) => string, +): string { + return translatePerpsError(error, t) ?? t('somethingWentWrong'); +} diff --git a/ui/hooks/perps/index.ts b/ui/hooks/perps/index.ts index 6ed15e662bb3..947bcbbb3028 100644 --- a/ui/hooks/perps/index.ts +++ b/ui/hooks/perps/index.ts @@ -1,5 +1,7 @@ export { usePerpsOrderForm } from './usePerpsOrderForm'; export { usePerpsEligibility } from './usePerpsEligibility'; +export { usePerpsMeasurement } from './usePerpsMeasurement'; +export { usePerpsLifecycleBreadcrumbs } from './usePerpsLifecycleBreadcrumbs'; export type { UsePerpsOrderFormOptions, UsePerpsOrderFormReturn, diff --git a/ui/hooks/perps/usePerpsLifecycleBreadcrumbs.test.ts b/ui/hooks/perps/usePerpsLifecycleBreadcrumbs.test.ts new file mode 100644 index 000000000000..e6229cb00d12 --- /dev/null +++ b/ui/hooks/perps/usePerpsLifecycleBreadcrumbs.test.ts @@ -0,0 +1,133 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { usePerpsLifecycleBreadcrumbs } from './usePerpsLifecycleBreadcrumbs'; + +describe('usePerpsLifecycleBreadcrumbs', () => { + afterEach(() => { + jest.clearAllMocks(); + delete (globalThis as Record).sentry; + }); + + it('does not throw when sentry is not initialized', () => { + expect(() => { + renderHook(() => usePerpsLifecycleBreadcrumbs()); + }).not.toThrow(); + }); + + it('adds a breadcrumb on mount', () => { + const mockAddBreadcrumb = jest.fn(); + (globalThis as Record).sentry = { + addBreadcrumb: mockAddBreadcrumb, + }; + + renderHook(() => usePerpsLifecycleBreadcrumbs()); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'perps.lifecycle', + message: 'Perps popup opened', + level: 'info', + }), + ); + }); + + it('adds a breadcrumb when the document becomes hidden', () => { + const mockAddBreadcrumb = jest.fn(); + (globalThis as Record).sentry = { + addBreadcrumb: mockAddBreadcrumb, + }; + + renderHook(() => usePerpsLifecycleBreadcrumbs()); + mockAddBreadcrumb.mockClear(); + + act(() => { + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => true, + }); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'perps.lifecycle', + message: 'Perps popup hidden', + level: 'info', + }), + ); + }); + + it('adds a breadcrumb when the document becomes visible', () => { + const mockAddBreadcrumb = jest.fn(); + (globalThis as Record).sentry = { + addBreadcrumb: mockAddBreadcrumb, + }; + + renderHook(() => usePerpsLifecycleBreadcrumbs()); + mockAddBreadcrumb.mockClear(); + + act(() => { + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'perps.lifecycle', + message: 'Perps popup visible', + level: 'info', + }), + ); + }); + + it('adds a breadcrumb on beforeunload', () => { + const mockAddBreadcrumb = jest.fn(); + (globalThis as Record).sentry = { + addBreadcrumb: mockAddBreadcrumb, + }; + + renderHook(() => usePerpsLifecycleBreadcrumbs()); + mockAddBreadcrumb.mockClear(); + + act(() => { + window.dispatchEvent(new Event('beforeunload')); + }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'perps.lifecycle', + message: 'Perps popup closing', + level: 'info', + }), + ); + }); + + it('removes event listeners on unmount', () => { + const mockAddBreadcrumb = jest.fn(); + (globalThis as Record).sentry = { + addBreadcrumb: mockAddBreadcrumb, + }; + + const { unmount } = renderHook(() => usePerpsLifecycleBreadcrumbs()); + + unmount(); + mockAddBreadcrumb.mockClear(); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + window.dispatchEvent(new Event('beforeunload')); + }); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/hooks/perps/usePerpsLifecycleBreadcrumbs.ts b/ui/hooks/perps/usePerpsLifecycleBreadcrumbs.ts new file mode 100644 index 000000000000..47f44b73a56d --- /dev/null +++ b/ui/hooks/perps/usePerpsLifecycleBreadcrumbs.ts @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; + +/** + * Adds Sentry breadcrumbs for Perps popup lifecycle events. + * + * Tracks when the extension popup is hidden (closed by the user or another window + * is focused) while Perps is active. These breadcrumbs appear in Sentry event + * detail views and help diagnose errors caused by partial operations that were + * interrupted by popup close. + * + * Uses `visibilitychange` (reliable in the extension popup) as the primary + * mechanism. The `beforeunload` event fires just before teardown and records + * the popup close itself. + */ +export function usePerpsLifecycleBreadcrumbs(): void { + useEffect(() => { + globalThis.sentry?.addBreadcrumb?.({ + category: 'perps.lifecycle', + message: 'Perps popup opened', + level: 'info', + }); + + const handleVisibilityChange = () => { + if (document.hidden) { + globalThis.sentry?.addBreadcrumb?.({ + category: 'perps.lifecycle', + message: 'Perps popup hidden', + level: 'info', + data: { visibilityState: document.visibilityState }, + }); + } else { + globalThis.sentry?.addBreadcrumb?.({ + category: 'perps.lifecycle', + message: 'Perps popup visible', + level: 'info', + }); + } + }; + + const handleBeforeUnload = () => { + globalThis.sentry?.addBreadcrumb?.({ + category: 'perps.lifecycle', + message: 'Perps popup closing', + level: 'info', + }); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, []); +} diff --git a/ui/hooks/perps/usePerpsMeasurement.test.ts b/ui/hooks/perps/usePerpsMeasurement.test.ts new file mode 100644 index 000000000000..8c771c03b1a9 --- /dev/null +++ b/ui/hooks/perps/usePerpsMeasurement.test.ts @@ -0,0 +1,132 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { usePerpsMeasurement } from './usePerpsMeasurement'; + +describe('usePerpsMeasurement', () => { + afterEach(() => { + jest.clearAllMocks(); + delete (globalThis as Record).sentry; + }); + + it('does not call setMeasurement when isReady is false', () => { + const mockSetMeasurement = jest.fn(); + (globalThis as Record).sentry = { + setMeasurement: mockSetMeasurement, + }; + + renderHook(() => usePerpsMeasurement('PerpsTabLoaded', false)); + + expect(mockSetMeasurement).not.toHaveBeenCalled(); + }); + + it('calls setMeasurement with the correct name and unit when isReady becomes true', () => { + const mockSetMeasurement = jest.fn(); + (globalThis as Record).sentry = { + setMeasurement: mockSetMeasurement, + }; + + const { rerender } = renderHook( + ({ isReady }: { isReady: boolean }) => + usePerpsMeasurement('PerpsTabLoaded', isReady), + { initialProps: { isReady: false } }, + ); + + expect(mockSetMeasurement).not.toHaveBeenCalled(); + + rerender({ isReady: true }); + + expect(mockSetMeasurement).toHaveBeenCalledTimes(1); + expect(mockSetMeasurement).toHaveBeenCalledWith( + 'PerpsTabLoaded', + expect.any(Number), + 'millisecond', + ); + }); + + it('reports a non-negative duration', () => { + const mockSetMeasurement = jest.fn(); + (globalThis as Record).sentry = { + setMeasurement: mockSetMeasurement, + }; + + const { rerender } = renderHook( + ({ isReady }: { isReady: boolean }) => + usePerpsMeasurement('PerpsTabLoaded', isReady), + { initialProps: { isReady: false } }, + ); + + rerender({ isReady: true }); + + const duration = mockSetMeasurement.mock.calls[0][1] as number; + expect(duration).toBeGreaterThanOrEqual(0); + }); + + it('only reports once even if isReady toggles back and forth', () => { + const mockSetMeasurement = jest.fn(); + (globalThis as Record).sentry = { + setMeasurement: mockSetMeasurement, + }; + + const { rerender } = renderHook( + ({ isReady }: { isReady: boolean }) => + usePerpsMeasurement('PerpsTabLoaded', isReady), + { initialProps: { isReady: false } }, + ); + + rerender({ isReady: true }); + rerender({ isReady: false }); + rerender({ isReady: true }); + + expect(mockSetMeasurement).toHaveBeenCalledTimes(1); + }); + + it('calls setMeasurement immediately when mounted with isReady=true', () => { + const mockSetMeasurement = jest.fn(); + (globalThis as Record).sentry = { + setMeasurement: mockSetMeasurement, + }; + + renderHook(() => usePerpsMeasurement('PerpsMarketDetailLoaded', true)); + + expect(mockSetMeasurement).toHaveBeenCalledTimes(1); + expect(mockSetMeasurement).toHaveBeenCalledWith( + 'PerpsMarketDetailLoaded', + expect.any(Number), + 'millisecond', + ); + }); + + it('does not throw when sentry is not initialized', () => { + const { rerender } = renderHook( + ({ isReady }: { isReady: boolean }) => + usePerpsMeasurement('PerpsTabLoaded', isReady), + { initialProps: { isReady: false } }, + ); + + expect(() => { + act(() => { + rerender({ isReady: true }); + }); + }).not.toThrow(); + }); + + it('uses the provided measurement name', () => { + const mockSetMeasurement = jest.fn(); + (globalThis as Record).sentry = { + setMeasurement: mockSetMeasurement, + }; + + const { rerender } = renderHook( + ({ isReady }: { isReady: boolean }) => + usePerpsMeasurement('PerpsAssetScreenLoaded', isReady), + { initialProps: { isReady: false } }, + ); + + rerender({ isReady: true }); + + expect(mockSetMeasurement).toHaveBeenCalledWith( + 'PerpsAssetScreenLoaded', + expect.any(Number), + 'millisecond', + ); + }); +}); diff --git a/ui/hooks/perps/usePerpsMeasurement.ts b/ui/hooks/perps/usePerpsMeasurement.ts new file mode 100644 index 000000000000..956ff78848b7 --- /dev/null +++ b/ui/hooks/perps/usePerpsMeasurement.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; + +/** + * Measures the time from component mount to when data becomes ready + * and reports it as a Sentry custom measurement. + * + * Intended for tracking screen load performance on key Perps views. + * Fires once per component lifecycle -- subsequent `isReady` changes + * after the first `true` are ignored. + * + * @param measurementName - Sentry measurement name (e.g. 'PerpsTabLoaded'). + * @param isReady - Set to `true` once the view's primary data has loaded. + */ +export function usePerpsMeasurement( + measurementName: string, + isReady: boolean, +): void { + const mountTimeRef = useRef(performance.now()); + const hasReportedRef = useRef(false); + + useEffect(() => { + if (isReady && !hasReportedRef.current) { + hasReportedRef.current = true; + const duration = performance.now() - mountTimeRef.current; + globalThis.sentry?.setMeasurement?.( + measurementName, + duration, + 'millisecond', + ); + } + }, [isReady, measurementName]); +} diff --git a/ui/pages/perps/perps-market-detail-page.tsx b/ui/pages/perps/perps-market-detail-page.tsx index 51b43265b58e..38aea83e3bd5 100644 --- a/ui/pages/perps/perps-market-detail-page.tsx +++ b/ui/pages/perps/perps-market-detail-page.tsx @@ -50,6 +50,7 @@ import { import { usePerpsEligibility, usePerpsMarketFills } from '../../hooks/perps'; import { getPerpsStreamManager } from '../../providers/perps'; import { submitRequestToBackground } from '../../store/background-connection'; +import { usePerpsMeasurement } from '../../hooks/perps/usePerpsMeasurement'; import { OrderCard } from '../../components/app/perps/order-card'; import { TransactionCard } from '../../components/app/perps/transaction-card'; import { PerpsTokenLogo } from '../../components/app/perps/perps-token-logo'; @@ -281,6 +282,8 @@ const PerpsMarketDetailPage: React.FC = () => { const { markets: allMarkets, isInitialLoading: marketsLoading } = usePerpsLiveMarketData(); + usePerpsMeasurement('PerpsMarketDetailLoaded', !marketsLoading); + // Safely decode the symbol from URL const decodedSymbol = useMemo(() => { if (!symbol) { From f5acba3eb76a56b00b9cc2800d7ecb1534b12cdc Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 12:06:44 -1000 Subject: [PATCH 02/22] chore: format --- app/scripts/controllers/perps/infrastructure.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/scripts/controllers/perps/infrastructure.test.ts b/app/scripts/controllers/perps/infrastructure.test.ts index 5c4b27b8b494..812c702967d9 100644 --- a/app/scripts/controllers/perps/infrastructure.test.ts +++ b/app/scripts/controllers/perps/infrastructure.test.ts @@ -252,7 +252,6 @@ describe('createPerpsInfrastructure', () => { tracer.setMeasurement('test', 100, 'millisecond'), ).not.toThrow(); }); - }); describe('when sentry is available', () => { @@ -386,7 +385,6 @@ describe('createPerpsInfrastructure', () => { 'millisecond', ); }); - }); }); From 8c2701cf441cf98825112ed4d940a34cb62207e8 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 12:26:43 -1000 Subject: [PATCH 03/22] fix: bugbot --- app/_locales/en/messages.json | 3 +++ .../app/perps/utils/translate-perps-error.test.ts | 12 ++++++++++++ .../app/perps/utils/translate-perps-error.ts | 4 ++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a0c2e45237c5..cc1dd6c1c283 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5952,6 +5952,9 @@ "perpsWithdrawInvalidAmount": { "message": "Enter a valid amount." }, + "perpsWithdrawInvalidAddress": { + "message": "Enter a valid destination address." + }, "perpsWithdrawMinNotice": { "message": "Minimum withdrawal: $1 USDC", "description": "$1 is the minimum amount (e.g. 1.01)" diff --git a/ui/components/app/perps/utils/translate-perps-error.test.ts b/ui/components/app/perps/utils/translate-perps-error.test.ts index 853ae9c72913..a188f07015f0 100644 --- a/ui/components/app/perps/utils/translate-perps-error.test.ts +++ b/ui/components/app/perps/utils/translate-perps-error.test.ts @@ -89,6 +89,18 @@ describe('ERROR_CODE_TO_I18N_KEY', () => { ).toBe('perpsWithdrawInsufficient'); }); + it('maps WITHDRAW_INVALID_DESTINATION to perpsWithdrawInvalidAddress', () => { + expect( + ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.WITHDRAW_INVALID_DESTINATION], + ).toBe('perpsWithdrawInvalidAddress'); + }); + + it('maps INVALID_ADDRESS_FORMAT to perpsWithdrawInvalidAddress', () => { + expect( + ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT], + ).toBe('perpsWithdrawInvalidAddress'); + }); + it('maps NO_ACCOUNT_SELECTED to perpsWithdrawNoAccount', () => { expect(ERROR_CODE_TO_I18N_KEY[PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED]).toBe( 'perpsWithdrawNoAccount', diff --git a/ui/components/app/perps/utils/translate-perps-error.ts b/ui/components/app/perps/utils/translate-perps-error.ts index 1c1e94c9ccaa..cdedf0edbfc6 100644 --- a/ui/components/app/perps/utils/translate-perps-error.ts +++ b/ui/components/app/perps/utils/translate-perps-error.ts @@ -35,7 +35,7 @@ export const ERROR_CODE_TO_I18N_KEY: Record = { [PERPS_ERROR_CODES.WITHDRAW_AMOUNT_REQUIRED]: 'perpsWithdrawInvalidAmount', [PERPS_ERROR_CODES.WITHDRAW_AMOUNT_POSITIVE]: 'perpsWithdrawInvalidAmount', [PERPS_ERROR_CODES.WITHDRAW_INVALID_DESTINATION]: - 'perpsWithdrawInvalidAmount', + 'perpsWithdrawInvalidAddress', [PERPS_ERROR_CODES.WITHDRAW_ASSET_NOT_SUPPORTED]: 'somethingWentWrong', [PERPS_ERROR_CODES.WITHDRAW_INSUFFICIENT_BALANCE]: 'perpsWithdrawInsufficient', @@ -67,7 +67,7 @@ export const ERROR_CODE_TO_I18N_KEY: Record = { [PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED]: 'perpsWithdrawNoAccount', // KEYRING_LOCKED is handled silently and never surfaced to the user [PERPS_ERROR_CODES.KEYRING_LOCKED]: 'somethingWentWrong', - [PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT]: 'perpsWithdrawInvalidAmount', + [PERPS_ERROR_CODES.INVALID_ADDRESS_FORMAT]: 'perpsWithdrawInvalidAddress', // Transfer / swap [PERPS_ERROR_CODES.TRANSFER_FAILED]: 'somethingWentWrong', From ca02f44cb8432201b19176c3a8b768531ae16fba Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 12:48:56 -1000 Subject: [PATCH 04/22] fix: locale --- app/_locales/en/messages.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index cc1dd6c1c283..22989cfb4b20 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5949,12 +5949,12 @@ "perpsWithdrawInsufficient": { "message": "Amount exceeds your available Perps balance." }, - "perpsWithdrawInvalidAmount": { - "message": "Enter a valid amount." - }, "perpsWithdrawInvalidAddress": { "message": "Enter a valid destination address." }, + "perpsWithdrawInvalidAmount": { + "message": "Enter a valid amount." + }, "perpsWithdrawMinNotice": { "message": "Minimum withdrawal: $1 USDC", "description": "$1 is the minimum amount (e.g. 1.01)" From 74a33b3631af56bf234c1956ca3ae1782f81c657 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 13:08:05 -1000 Subject: [PATCH 05/22] chore: lint --- app/_locales/en_GB/messages.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index a0c2e45237c5..22989cfb4b20 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5949,6 +5949,9 @@ "perpsWithdrawInsufficient": { "message": "Amount exceeds your available Perps balance." }, + "perpsWithdrawInvalidAddress": { + "message": "Enter a valid destination address." + }, "perpsWithdrawInvalidAmount": { "message": "Enter a valid amount." }, From eb706941b4d538d4824b91dd580cdb1e673b8015 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 13:28:14 -1000 Subject: [PATCH 06/22] fix: bugbot --- .../controllers/perps/infrastructure.test.ts | 58 +++++++++++++++++++ .../controllers/perps/infrastructure.ts | 26 ++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/app/scripts/controllers/perps/infrastructure.test.ts b/app/scripts/controllers/perps/infrastructure.test.ts index 812c702967d9..b48db3de932a 100644 --- a/app/scripts/controllers/perps/infrastructure.test.ts +++ b/app/scripts/controllers/perps/infrastructure.test.ts @@ -372,6 +372,64 @@ describe('createPerpsInfrastructure', () => { expect(mockSpan.end).toHaveBeenCalledTimes(1); }); + it('ends the previous span when trace is called with a duplicate key', () => { + const firstSpan = { setAttribute: jest.fn(), end: jest.fn() }; + const secondSpan = { setAttribute: jest.fn(), end: jest.fn() }; + let callCount = 0; + const startSpanManual = jest.fn((_opts, cb) => { + cb(callCount === 0 ? firstSpan : secondSpan); + callCount += 1; + }); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'dup', + op: 'perps.order', + }); + tracer.trace({ + name: 'Perps Place Order' as never, + id: 'dup', + op: 'perps.order', + }); + + expect(firstSpan.end).toHaveBeenCalledTimes(1); + }); + + it('evicts the oldest span when the pending map reaches capacity', () => { + const spans: { setAttribute: jest.Mock; end: jest.Mock }[] = []; + const startSpanManual = jest.fn((_opts, cb) => { + const span = { setAttribute: jest.fn(), end: jest.fn() }; + spans.push(span); + cb(span); + }); + (globalThis as Record).sentry = { startSpanManual }; + + const { tracer } = createPerpsInfrastructure(); + + // Fill the map to capacity (MAX_PENDING_SPANS = 50) + for (let i = 0; i < 50; i++) { + tracer.trace({ + name: 'Perps Place Order' as never, + id: String(i), + op: 'perps.order', + }); + } + + // The first span should still be pending — map is exactly at capacity + expect(spans[0].end).not.toHaveBeenCalled(); + + // One more trace pushes the map over capacity, evicting span[0] + tracer.trace({ + name: 'Perps Place Order' as never, + id: '50', + op: 'perps.order', + }); + + expect(spans[0].end).toHaveBeenCalledTimes(1); + }); + it('calls setMeasurement on sentry', () => { const setMeasurement = jest.fn(); (globalThis as Record).sentry = { setMeasurement }; diff --git a/app/scripts/controllers/perps/infrastructure.ts b/app/scripts/controllers/perps/infrastructure.ts index 7c2a46eba039..4053b608feb0 100644 --- a/app/scripts/controllers/perps/infrastructure.ts +++ b/app/scripts/controllers/perps/infrastructure.ts @@ -77,6 +77,8 @@ function createPerformance(): PerpsPerformance { }; } +const MAX_PENDING_SPANS = 50; + function createTracer(): PerpsTracer { const pendingSpans = new Map< string, @@ -98,6 +100,28 @@ function createTracer(): PerpsTracer { if (!startSpanManual) { return; } + + const key = `${params.name}:${params.id}`; + + // End any existing span with the same key before overwriting to avoid + // leaking the old span reference when trace() is called twice with the + // same name/id pair. + const existing = pendingSpans.get(key); + if (existing) { + existing.end(); + pendingSpans.delete(key); + } + + // Evict the oldest pending span when the map is at capacity so the map + // cannot grow unboundedly over long browser sessions. + if (pendingSpans.size >= MAX_PENDING_SPANS) { + const oldestKey = pendingSpans.keys().next().value; + if (oldestKey !== undefined) { + pendingSpans.get(oldestKey)?.end(); + pendingSpans.delete(oldestKey); + } + } + startSpanManual( { name: params.name, @@ -108,7 +132,7 @@ function createTracer(): PerpsTracer { setAttribute: (key: string, value: PerpsTraceValue) => void; end: () => void; }) => { - pendingSpans.set(`${params.name}:${params.id}`, span); + pendingSpans.set(key, span); }, ); }, From 329db6074c6792f8dfa0c8d1e4d70bb8f0d7df59 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 17:26:45 -1000 Subject: [PATCH 07/22] chore: persist listeners --- ui/components/app/perps/perps-view.tsx | 3 +-- ui/pages/perps/perps-layout.tsx | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/components/app/perps/perps-view.tsx b/ui/components/app/perps/perps-view.tsx index cc5beab55c3d..04e131bd5c3e 100644 --- a/ui/components/app/perps/perps-view.tsx +++ b/ui/components/app/perps/perps-view.tsx @@ -28,7 +28,7 @@ import { } from '../../../ducks/perps'; import { usePerpsMeasurement } from '../../../hooks/perps/usePerpsMeasurement'; -import { usePerpsLifecycleBreadcrumbs } from '../../../hooks/perps/usePerpsLifecycleBreadcrumbs'; + import { usePerpsDepositConfirmation } from './hooks/usePerpsDepositConfirmation'; import { usePerpsWithdrawNavigation } from './hooks/usePerpsWithdrawNavigation'; import { PerpsBalanceDropdown } from './perps-balance-dropdown'; @@ -184,7 +184,6 @@ export const PerpsView: React.FC = () => { const isLoading = positionsLoading || ordersLoading || marketsLoading; usePerpsMeasurement('PerpsTabLoaded', !isLoading); - usePerpsLifecycleBreadcrumbs(); // Auto-open tutorial modal the first time a user enters the perps domain. // Guards on both the backend isFirstTimeUser flag (stable once propagated) and diff --git a/ui/pages/perps/perps-layout.tsx b/ui/pages/perps/perps-layout.tsx index 3c8a22719836..e864fadf1a0f 100644 --- a/ui/pages/perps/perps-layout.tsx +++ b/ui/pages/perps/perps-layout.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Outlet } from 'react-router-dom'; import { PerpsToastProvider } from '../../components/app/perps'; import { usePerpsViewActive } from '../../hooks/perps/stream/usePerpsViewActive'; +import { usePerpsLifecycleBreadcrumbs } from '../../hooks/perps/usePerpsLifecycleBreadcrumbs'; /** * Layout wrapper for all Perps pages. @@ -16,6 +17,7 @@ import { usePerpsViewActive } from '../../hooks/perps/stream/usePerpsViewActive' */ export default function PerpsLayout() { usePerpsViewActive('PerpsLayout'); + usePerpsLifecycleBreadcrumbs(); return ( From ff1e058d3cf7ecd15facbfcf3c642f670e74aa39 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 17:40:16 -1000 Subject: [PATCH 08/22] fix: gate measurement on loading states --- ui/pages/perps/perps-market-detail-page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/pages/perps/perps-market-detail-page.tsx b/ui/pages/perps/perps-market-detail-page.tsx index 38aea83e3bd5..5e4ec8cc2e32 100644 --- a/ui/pages/perps/perps-market-detail-page.tsx +++ b/ui/pages/perps/perps-market-detail-page.tsx @@ -282,8 +282,6 @@ const PerpsMarketDetailPage: React.FC = () => { const { markets: allMarkets, isInitialLoading: marketsLoading } = usePerpsLiveMarketData(); - usePerpsMeasurement('PerpsMarketDetailLoaded', !marketsLoading); - // Safely decode the symbol from URL const decodedSymbol = useMemo(() => { if (!symbol) { @@ -422,6 +420,11 @@ const PerpsMarketDetailPage: React.FC = () => { return transactions.slice(0, PERPS_CONSTANTS.RECENT_ACTIVITY_LIMIT); }, [marketFills]); + usePerpsMeasurement( + 'PerpsMarketDetailLoaded', + !marketsLoading && !isCandleLoading && !fillsLoading, + ); + // OHLCV bar state: the candle currently hovered by crosshair (null = no hover) // const [hoveredCandle, setHoveredCandle] = useState(null); From 603b2ef54c3fca21ad0d3dbe4c89233a99361622 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 18:37:38 -1000 Subject: [PATCH 09/22] refactor: wire up sentry callsites --- .../close-position/close-position-modal.tsx | 15 +++---- .../reverse-position-modal.tsx | 10 ++--- ui/pages/perps/perps-order-entry-page.tsx | 42 ++----------------- ui/pages/perps/perps-withdraw-page.tsx | 18 ++++++-- 4 files changed, 30 insertions(+), 55 deletions(-) diff --git a/ui/components/app/perps/close-position/close-position-modal.tsx b/ui/components/app/perps/close-position/close-position-modal.tsx index 1dce2728a4c9..a98f9b89b7ab 100644 --- a/ui/components/app/perps/close-position/close-position-modal.tsx +++ b/ui/components/app/perps/close-position/close-position-modal.tsx @@ -32,6 +32,7 @@ import { getPositionDirection, getPositionPnlRatio, } from '../utils'; +import { handlePerpsError } from '../utils/translate-perps-error'; import { PERPS_MARKET_ORDER_FEE_RATE, PERPS_MIN_MARKET_ORDER_USD, @@ -198,15 +199,11 @@ const getCloseFailureToastConfig = ({ const isOrderSizeMinError = error instanceof Error && error.message === 'ORDER_SIZE_MIN'; - let errorMessage = 'An unknown error occurred'; - - if (isOrderSizeMinError) { - errorMessage = t('perpsClosePartialMinNotional', [ - formatCurrencyWithMinThreshold(PERPS_MIN_MARKET_ORDER_USD, 'USD'), - ]); - } else if (error instanceof Error) { - errorMessage = error.message; - } + const errorMessage = isOrderSizeMinError + ? t('perpsClosePartialMinNotional', [ + formatCurrencyWithMinThreshold(PERPS_MIN_MARKET_ORDER_USD, 'USD'), + ]) + : handlePerpsError(error, t as (key: string) => string); if (isPartialClose) { return { diff --git a/ui/components/app/perps/reverse-position/reverse-position-modal.tsx b/ui/components/app/perps/reverse-position/reverse-position-modal.tsx index d5b14fde1aef..115ab4a0e187 100644 --- a/ui/components/app/perps/reverse-position/reverse-position-modal.tsx +++ b/ui/components/app/perps/reverse-position/reverse-position-modal.tsx @@ -23,6 +23,7 @@ import { useI18nContext } from '../../../../hooks/useI18nContext'; import { submitRequestToBackground } from '../../../../store/background-connection'; import { getPerpsStreamManager } from '../../../../providers/perps'; import { getPositionDirection } from '../utils'; +import { handlePerpsError } from '../utils/translate-perps-error'; import { PERPS_TOAST_KEYS, usePerpsToast } from '../perps-toast'; import type { Position } from '../types'; @@ -111,17 +112,16 @@ export const ReversePositionModal: React.FC = ({ replacePerpsToastByKey({ key: PERPS_TOAST_KEYS.REVERSE_SUCCESS }); onClose(); } catch (err) { - const raw = - err instanceof Error ? err.message : 'An unknown error occurred'; - setError(raw); + const message = handlePerpsError(err, t as (key: string) => string); + setError(message); replacePerpsToastByKey({ key: PERPS_TOAST_KEYS.REVERSE_FAILED, - description: raw, + description: message, }); } finally { setIsSubmitting(false); } - }, [onClose, position.symbol, positionForFlip, replacePerpsToastByKey]); + }, [onClose, position.symbol, positionForFlip, replacePerpsToastByKey, t]); return ( { if (inProgressToastKey) { hidePerpsToast(); } - const errorMessage = - error instanceof Error ? error.message : 'An unknown error occurred'; const failedToastKey = ORDER_MODE_TOAST_KEYS[orderMode].failed; - const normalizedErrorMessage = errorMessage.trim(); - const shouldUseOrderFailedFallback = - failedToastKey === PERPS_TOAST_KEYS.ORDER_FAILED && - (normalizedErrorMessage.length === 0 || - ORDER_FAILED_FALLBACK_ERROR_PATTERNS.some((pattern) => - pattern.test(normalizedErrorMessage), - ) || - !ORDER_FAILED_USER_FACING_ERROR_PATTERNS.some((pattern) => - pattern.test(normalizedErrorMessage), - )); - const failedToastDescription = shouldUseOrderFailedFallback - ? t('perpsToastOrderFailedDescriptionFallback') - : normalizedErrorMessage; + const failedToastDescription = + translatePerpsError(error, t as (key: string) => string) ?? + t('perpsToastOrderFailedDescriptionFallback'); replacePerpsToastByKey({ key: failedToastKey, diff --git a/ui/pages/perps/perps-withdraw-page.tsx b/ui/pages/perps/perps-withdraw-page.tsx index fc44de35da5d..55915422e403 100644 --- a/ui/pages/perps/perps-withdraw-page.tsx +++ b/ui/pages/perps/perps-withdraw-page.tsx @@ -50,6 +50,7 @@ import { usePerpsEligibility } from '../../hooks/perps'; import { usePerpsLiveAccount } from '../../hooks/perps/stream'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; import { submitRequestToBackground } from '../../store/background-connection'; +import { translatePerpsError } from '../../components/app/perps/utils/translate-perps-error'; import { formatAmountInputFromNumber } from './perps-withdraw-amount-format'; /** Arbitrum native USDC (matches `ARBITRUM_USDC_TOKEN_OBJECT` in swaps constants). */ @@ -247,12 +248,23 @@ const PerpsWithdrawPage: React.FC = () => { return; } - setSubmitError(result?.error ?? t('perpsWithdrawFailed')); + const withdrawError = result?.error; + setSubmitError( + withdrawError + ? (translatePerpsError( + new Error(withdrawError), + t as (key: string) => string, + ) ?? t('perpsWithdrawFailed')) + : t('perpsWithdrawFailed'), + ); submitRequestToBackground('perpsClearWithdrawResult', []).catch(() => { // Non-blocking cleanup of controller toast state }); - } catch { - setSubmitError(t('perpsWithdrawFailed')); + } catch (error) { + setSubmitError( + translatePerpsError(error, t as (key: string) => string) ?? + t('perpsWithdrawFailed'), + ); submitRequestToBackground('perpsClearWithdrawResult', []).catch(() => { // Non-blocking cleanup of controller toast state }); From 032780eb61c16feaea45ccb6449e79777dd051ae Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 18:38:58 -1000 Subject: [PATCH 10/22] chore: error code typing --- ui/components/app/perps/utils/translate-perps-error.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/components/app/perps/utils/translate-perps-error.ts b/ui/components/app/perps/utils/translate-perps-error.ts index cdedf0edbfc6..fc4ca4600046 100644 --- a/ui/components/app/perps/utils/translate-perps-error.ts +++ b/ui/components/app/perps/utils/translate-perps-error.ts @@ -184,12 +184,12 @@ export function translatePerpsError( errorObj && 'code' in errorObj && typeof (errorObj as { code?: unknown }).code === 'string' - ? ((errorObj as { code: string }).code as PerpsErrorCode) + ? (errorObj as { code: string }).code : null; - // 1. Code-first lookup + // 1. Code-first lookup — narrow to PerpsErrorCode only after membership check if (errorCode && errorCode in ERROR_CODE_TO_I18N_KEY) { - const i18nKey = ERROR_CODE_TO_I18N_KEY[errorCode]; + const i18nKey = ERROR_CODE_TO_I18N_KEY[errorCode as PerpsErrorCode]; return t(i18nKey); } From 54f7ce50c02f06a816898dffb1aa7c06dbe072d2 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 7 Apr 2026 18:40:05 -1000 Subject: [PATCH 11/22] chore: cleanup sentry mock --- .../controllers/perps/infrastructure.test.ts | 83 +++++-------------- 1 file changed, 20 insertions(+), 63 deletions(-) diff --git a/app/scripts/controllers/perps/infrastructure.test.ts b/app/scripts/controllers/perps/infrastructure.test.ts index b48db3de932a..03db102d02cf 100644 --- a/app/scripts/controllers/perps/infrastructure.test.ts +++ b/app/scripts/controllers/perps/infrastructure.test.ts @@ -5,6 +5,19 @@ jest.mock('../../../../shared/lib/sentry', () => ({ captureException: (...args: unknown[]) => mockCaptureException(...args), })); +function setupSentryScope() { + const mockScope = { + setTag: jest.fn(), + setContext: jest.fn(), + setExtras: jest.fn(), + }; + const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => + cb(mockScope), + ); + (globalThis as Record).sentry = { withScope }; + return mockScope; +} + describe('createPerpsInfrastructure', () => { afterEach(() => { jest.clearAllMocks(); @@ -56,15 +69,7 @@ describe('createPerpsInfrastructure', () => { describe('when sentry.withScope is available', () => { it('always sets the feature:perps tag', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + const mockScope = setupSentryScope(); const { logger } = createPerpsInfrastructure(); logger.error(new Error('test')); @@ -73,15 +78,7 @@ describe('createPerpsInfrastructure', () => { }); it('forwards errors to captureException inside the scope', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + setupSentryScope(); const { logger } = createPerpsInfrastructure(); const error = new Error('test error'); @@ -91,15 +88,7 @@ describe('createPerpsInfrastructure', () => { }); it('sets extra tags from options on the scope', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + const mockScope = setupSentryScope(); const { logger } = createPerpsInfrastructure(); logger.error(new Error('test'), { @@ -114,15 +103,7 @@ describe('createPerpsInfrastructure', () => { }); it('converts numeric tag values to strings', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + const mockScope = setupSentryScope(); const { logger } = createPerpsInfrastructure(); logger.error(new Error('test'), { tags: { retryCount: 3 } }); @@ -131,15 +112,7 @@ describe('createPerpsInfrastructure', () => { }); it('sets Sentry context from options', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + const mockScope = setupSentryScope(); const { logger } = createPerpsInfrastructure(); logger.error(new Error('test'), { @@ -156,15 +129,7 @@ describe('createPerpsInfrastructure', () => { }); it('sets Sentry extras from options', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + const mockScope = setupSentryScope(); const { logger } = createPerpsInfrastructure(); logger.error(new Error('test'), { @@ -177,15 +142,7 @@ describe('createPerpsInfrastructure', () => { }); it('works correctly when options are omitted', () => { - const mockScope = { - setTag: jest.fn(), - setContext: jest.fn(), - setExtras: jest.fn(), - }; - const withScope = jest.fn((cb: (scope: typeof mockScope) => void) => - cb(mockScope), - ); - (globalThis as Record).sentry = { withScope }; + const mockScope = setupSentryScope(); const { logger } = createPerpsInfrastructure(); const error = new Error('bare error'); From e7d165a57dd429a1f859526790fa8e22c594374b Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 05:51:53 -1000 Subject: [PATCH 12/22] fix: bugbot, jest config --- jest.config.js | 3 +++ ui/pages/perps/perps-order-entry-page.tsx | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/jest.config.js b/jest.config.js index 8b7b9483e086..a67e546e66a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -68,6 +68,9 @@ module.exports = { '/test/e2e/playwright/llm-workflow/**/*.test.(js|ts|tsx)', ], testPathIgnorePatterns: ['/development/webpack/'], + transformIgnorePatterns: [ + '/node_modules/(?!(@nktkas/hyperliquid|@noble/hashes)/)', + ], testTimeout: 5500, // We have to specify the environment we are running in, which is jsdom. The // default is 'node'. This can be modified *per file* using a comment at the diff --git a/ui/pages/perps/perps-order-entry-page.tsx b/ui/pages/perps/perps-order-entry-page.tsx index 58bbd653a08b..51c554647f46 100644 --- a/ui/pages/perps/perps-order-entry-page.tsx +++ b/ui/pages/perps/perps-order-entry-page.tsx @@ -685,9 +685,15 @@ const PerpsOrderEntryPage: React.FC = () => { hidePerpsToast(); } const failedToastKey = ORDER_MODE_TOAST_KEYS[orderMode].failed; + const translatedError = translatePerpsError( + error, + t as (key: string) => string, + ); const failedToastDescription = - translatePerpsError(error, t as (key: string) => string) ?? - t('perpsToastOrderFailedDescriptionFallback'); + translatedError ?? + (failedToastKey === PERPS_TOAST_KEYS.ORDER_FAILED + ? t('perpsToastOrderFailedDescriptionFallback') + : t('somethingWentWrong')); replacePerpsToastByKey({ key: failedToastKey, From 3aeaa36ccaff8fd440bdf290a721474604b4d83d Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 05:52:57 -1000 Subject: [PATCH 13/22] fix: undo jest config --- jest.config.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/jest.config.js b/jest.config.js index a67e546e66a2..8b7b9483e086 100644 --- a/jest.config.js +++ b/jest.config.js @@ -68,9 +68,6 @@ module.exports = { '/test/e2e/playwright/llm-workflow/**/*.test.(js|ts|tsx)', ], testPathIgnorePatterns: ['/development/webpack/'], - transformIgnorePatterns: [ - '/node_modules/(?!(@nktkas/hyperliquid|@noble/hashes)/)', - ], testTimeout: 5500, // We have to specify the environment we are running in, which is jsdom. The // default is 'node'. This can be modified *per file* using a comment at the From 7b6cc446b35e904b6e2f9213329074f89bb06ab4 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 06:55:32 -1000 Subject: [PATCH 14/22] fix: unit tests --- app/_locales/en/messages.json | 33 ++ app/_locales/en_GB/messages.json | 33 ++ .../close-position-modal.test.tsx | 63 +++- .../reverse-position-modal.test.tsx | 81 ++++- ui/pages/perps/perps-layout.test.tsx | 61 ++++ .../perps/perps-market-detail-page.test.tsx | 306 +++++++++--------- .../perps/perps-order-entry-page.test.tsx | 73 ++++- ui/pages/perps/perps-withdraw-page.test.tsx | 58 ++++ 8 files changed, 547 insertions(+), 161 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 22989cfb4b20..ca5588cea1f1 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5416,6 +5416,12 @@ "message": "available", "description": "is the available balance amount" }, + "perpsBatchCancelFailed": { + "message": "Failed to cancel one or more orders. Try again." + }, + "perpsBatchCloseFailed": { + "message": "Failed to close one or more positions. Try again." + }, "perpsAvailableBalance": { "message": "Available Perps balance: " }, @@ -5459,6 +5465,9 @@ "perpsConfirmCloseShort": { "message": "Close Short" }, + "perpsConnectionTimeout": { + "message": "Connection timed out. Please try again." + }, "perpsContactSupport": { "message": "Contact support" }, @@ -5474,6 +5483,9 @@ "perpsDepositTitle": { "message": "Funded perps account" }, + "perpsDepositFailed": { + "message": "Deposit could not be completed. Try again." + }, "perpsDeposits": { "message": "Deposits" }, @@ -5548,6 +5560,9 @@ "perpsIncludesPnl": { "message": "includes P&L $1" }, + "perpsInsufficientMargin": { + "message": "Insufficient margin to place this order." + }, "perpsLearnBasics": { "message": "Learn the basics of perps" }, @@ -5612,6 +5627,9 @@ "perpsMore": { "message": "More" }, + "perpsNetworkError": { + "message": "A network error occurred. Please try again." + }, "perpsNoMarketsFound": { "message": "No markets found" }, @@ -5641,6 +5659,12 @@ "perpsOraclePriceTooltip": { "message": "The median of external prices reported by validators, used for computing funding rate" }, + "perpsOrderFailed": { + "message": "Order could not be placed. Try again." + }, + "perpsOrderRejected": { + "message": "Your order was rejected. Try again." + }, "perpsOrders": { "message": "Orders" }, @@ -5653,6 +5677,9 @@ "perpsPositions": { "message": "Positions" }, + "perpsRateLimitExceeded": { + "message": "Too many requests. Please wait and try again." + }, "perpsRecentActivity": { "message": "Recent Activity" }, @@ -5693,6 +5720,12 @@ "perpsSearchMarkets": { "message": "Search markets" }, + "perpsServiceUnavailable": { + "message": "Service temporarily unavailable. Please try again later." + }, + "perpsSlippageExceeded": { + "message": "Slippage exceeded. Adjust your slippage tolerance and try again." + }, "perpsSeeAll": { "message": "See All" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 22989cfb4b20..ca5588cea1f1 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5416,6 +5416,12 @@ "message": "available", "description": "is the available balance amount" }, + "perpsBatchCancelFailed": { + "message": "Failed to cancel one or more orders. Try again." + }, + "perpsBatchCloseFailed": { + "message": "Failed to close one or more positions. Try again." + }, "perpsAvailableBalance": { "message": "Available Perps balance: " }, @@ -5459,6 +5465,9 @@ "perpsConfirmCloseShort": { "message": "Close Short" }, + "perpsConnectionTimeout": { + "message": "Connection timed out. Please try again." + }, "perpsContactSupport": { "message": "Contact support" }, @@ -5474,6 +5483,9 @@ "perpsDepositTitle": { "message": "Funded perps account" }, + "perpsDepositFailed": { + "message": "Deposit could not be completed. Try again." + }, "perpsDeposits": { "message": "Deposits" }, @@ -5548,6 +5560,9 @@ "perpsIncludesPnl": { "message": "includes P&L $1" }, + "perpsInsufficientMargin": { + "message": "Insufficient margin to place this order." + }, "perpsLearnBasics": { "message": "Learn the basics of perps" }, @@ -5612,6 +5627,9 @@ "perpsMore": { "message": "More" }, + "perpsNetworkError": { + "message": "A network error occurred. Please try again." + }, "perpsNoMarketsFound": { "message": "No markets found" }, @@ -5641,6 +5659,12 @@ "perpsOraclePriceTooltip": { "message": "The median of external prices reported by validators, used for computing funding rate" }, + "perpsOrderFailed": { + "message": "Order could not be placed. Try again." + }, + "perpsOrderRejected": { + "message": "Your order was rejected. Try again." + }, "perpsOrders": { "message": "Orders" }, @@ -5653,6 +5677,9 @@ "perpsPositions": { "message": "Positions" }, + "perpsRateLimitExceeded": { + "message": "Too many requests. Please wait and try again." + }, "perpsRecentActivity": { "message": "Recent Activity" }, @@ -5693,6 +5720,12 @@ "perpsSearchMarkets": { "message": "Search markets" }, + "perpsServiceUnavailable": { + "message": "Service temporarily unavailable. Please try again later." + }, + "perpsSlippageExceeded": { + "message": "Slippage exceeded. Adjust your slippage tolerance and try again." + }, "perpsSeeAll": { "message": "See All" }, diff --git a/ui/components/app/perps/close-position/close-position-modal.test.tsx b/ui/components/app/perps/close-position/close-position-modal.test.tsx index 67477255e995..087bfe841de8 100644 --- a/ui/components/app/perps/close-position/close-position-modal.test.tsx +++ b/ui/components/app/perps/close-position/close-position-modal.test.tsx @@ -7,6 +7,67 @@ import mockState from '../../../../../test/data/mock-state.json'; import { mockPositions } from '../mocks'; import { ClosePositionModal } from './close-position-modal'; +jest.mock('@metamask/perps-controller', () => ({ + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); + /** Matches rendered `perpsClosePartialMinNotional` after $1 is replaced with a formatted USD amount */ const PARTIAL_MIN_NOTIONAL_PATTERN = /Partial closes must be at least \$[\d,.]+ in USD value\. Increase the close amount or set the slider to 100%\./u; @@ -325,7 +386,7 @@ describe('ClosePositionModal', () => { await waitFor(() => { expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastCloseFailed', - description: 'Insufficient balance', + description: 'Amount exceeds your available Perps balance.', }); }); }); diff --git a/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx b/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx index 16c06e69250e..fc89b466c961 100644 --- a/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx +++ b/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx @@ -7,6 +7,67 @@ import { enLocale as messages } from '../../../../../test/lib/i18n-helpers'; import { mockPositions } from '../mocks'; import { ReversePositionModal } from './reverse-position-modal'; +jest.mock('@metamask/perps-controller', () => ({ + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); + const mockSubmitRequestToBackground = jest.fn(); const mockGetPerpsStreamManager = jest.fn(); const mockReplacePerpsToastByKey = jest.fn(); @@ -250,7 +311,9 @@ describe('ReversePositionModal', () => { fireEvent.click(screen.getByTestId('perps-reverse-position-modal-save')); await waitFor(() => { - expect(screen.getByText('Insufficient margin')).toBeInTheDocument(); + expect( + screen.getByText('Insufficient margin to place this order.'), + ).toBeInTheDocument(); }); }); @@ -267,7 +330,9 @@ describe('ReversePositionModal', () => { fireEvent.click(screen.getByTestId('perps-reverse-position-modal-save')); await waitFor(() => { - expect(screen.getByText('fail')).toBeInTheDocument(); + expect( + screen.getByText("We couldn't load this page."), + ).toBeInTheDocument(); }); expect(mockSubmitRequestToBackground).not.toHaveBeenCalledWith( @@ -297,7 +362,9 @@ describe('ReversePositionModal', () => { fireEvent.click(screen.getByTestId('perps-reverse-position-modal-save')); await waitFor(() => { - expect(screen.getByText('fail')).toBeInTheDocument(); + expect( + screen.getByText("We couldn't load this page."), + ).toBeInTheDocument(); }); expect(onClose).not.toHaveBeenCalled(); }); @@ -317,7 +384,9 @@ describe('ReversePositionModal', () => { fireEvent.click(screen.getByTestId('perps-reverse-position-modal-save')); await waitFor(() => { - expect(screen.getByText('Network error')).toBeInTheDocument(); + expect( + screen.getByText('A network error occurred. Please try again.'), + ).toBeInTheDocument(); }); }); }); @@ -368,7 +437,7 @@ describe('ReversePositionModal', () => { await waitFor(() => { expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastReverseFailed', - description: 'Insufficient margin', + description: 'Insufficient margin to place this order.', }); }); }); @@ -388,7 +457,7 @@ describe('ReversePositionModal', () => { await waitFor(() => { expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastReverseFailed', - description: 'Network error', + description: 'A network error occurred. Please try again.', }); }); }); diff --git a/ui/pages/perps/perps-layout.test.tsx b/ui/pages/perps/perps-layout.test.tsx index a41dd4dab6e2..398e788314dc 100644 --- a/ui/pages/perps/perps-layout.test.tsx +++ b/ui/pages/perps/perps-layout.test.tsx @@ -5,6 +5,67 @@ import { submitRequestToBackground } from '../../store/background-connection'; import mockState from '../../../test/data/mock-state.json'; import PerpsLayout from './perps-layout'; +jest.mock('@metamask/perps-controller', () => ({ + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); + jest.mock('../../store/background-connection', () => ({ submitRequestToBackground: jest.fn().mockResolvedValue(undefined), })); diff --git a/ui/pages/perps/perps-market-detail-page.test.tsx b/ui/pages/perps/perps-market-detail-page.test.tsx index fc8c89e05036..0633ecf69c81 100644 --- a/ui/pages/perps/perps-market-detail-page.test.tsx +++ b/ui/pages/perps/perps-market-detail-page.test.tsx @@ -14,6 +14,67 @@ import { mockTransactions, } from '../../components/app/perps/mocks'; +jest.mock('@metamask/perps-controller', () => ({ + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); + // Mock lightweight-charts to prevent DOM rendering issues in tests const mockPriceLine = { options: jest.fn() }; jest.mock('lightweight-charts', () => ({ @@ -224,6 +285,16 @@ jest.mock('react-router-dom', () => ({ // eslint-disable-next-line import-x/first import PerpsMarketDetailPage from './perps-market-detail-page'; +async function renderPage( + store: ReturnType>, +) { + let result!: ReturnType; + await act(async () => { + result = renderWithProvider(, store); + }); + return result; +} + describe('PerpsMarketDetailPage', () => { const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -255,25 +326,22 @@ describe('PerpsMarketDetailPage', () => { }); describe('when perps feature is enabled', () => { - it('renders market detail page for ETH', () => { + it('renders market detail page for ETH', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); expect(getByTestId('perps-market-detail-page')).toBeInTheDocument(); }); - it('renders return using percent semantics from controller values', () => { + it('renders return using percent semantics from controller values', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect(screen.getByText(/15\.79%/u)).toBeInTheDocument(); }); - it('shows handed-off perps toast and clears route state', () => { + it('shows handed-off perps toast and clears route state', async () => { mockUseLocation.mockReturnValue({ pathname: '/perps/market/ETH', search: '', @@ -284,7 +352,7 @@ describe('PerpsMarketDetailPage', () => { }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastOrderPlaced', @@ -296,51 +364,39 @@ describe('PerpsMarketDetailPage', () => { }); }); - it('displays market symbol and price', () => { + it('displays market symbol and price', async () => { const store = mockStore(createMockState(true)); - const { getByTestId, getByText } = renderWithProvider( - , - store, - ); + const { getByTestId, getByText } = await renderPage(store); expect(getByTestId('perps-market-detail-price')).toBeInTheDocument(); expect(getByText('ETH-USD')).toBeInTheDocument(); }); - it('renders market detail page for BTC', () => { + it('renders market detail page for BTC', async () => { mockUseParams.mockReturnValue({ symbol: 'BTC' }); const store = mockStore(createMockState(true)); - const { getByTestId, getByText } = renderWithProvider( - , - store, - ); + const { getByTestId, getByText } = await renderPage(store); expect(getByTestId('perps-market-detail-page')).toBeInTheDocument(); expect(getByText('BTC-USD')).toBeInTheDocument(); }); - it('displays back button', () => { + it('displays back button', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); expect( getByTestId('perps-market-detail-back-button'), ).toBeInTheDocument(); }); - it('navigates back when back button is clicked', () => { + it('navigates back when back button is clicked', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); const backButton = getByTestId('perps-market-detail-back-button'); backButton.click(); @@ -348,13 +404,10 @@ describe('PerpsMarketDetailPage', () => { expect(mockUseNavigate).toHaveBeenCalledWith('/'); }); - it('uses market 24h change as fallback when no live percent update exists', () => { + it('uses market 24h change as fallback when no live percent update exists', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); expect(getByTestId('perps-market-detail-change')).toHaveTextContent( '+2.56%', @@ -363,10 +416,7 @@ describe('PerpsMarketDetailPage', () => { it('uses live percentChange24h when the price stream provides it', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); await waitFor(() => { expect(mockPriceSubscribe).toHaveBeenCalled(); @@ -388,76 +438,58 @@ describe('PerpsMarketDetailPage', () => { }); }); - it('displays candlestick chart', () => { + it('displays candlestick chart', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); expect(getByTestId('perps-market-detail-chart')).toBeInTheDocument(); expect(getByTestId('perps-candlestick-chart')).toBeInTheDocument(); }); - it('displays favorite button', () => { + it('displays favorite button', async () => { const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); expect( getByTestId('perps-market-detail-favorite-button'), ).toBeInTheDocument(); }); - it('renders HIP-3 equity market (TSLA)', () => { + it('renders HIP-3 equity market (TSLA)', async () => { mockUseParams.mockReturnValue({ symbol: 'xyz:TSLA' }); const store = mockStore(createMockState(true)); - const { getByTestId, getByText } = renderWithProvider( - , - store, - ); + const { getByTestId, getByText } = await renderPage(store); expect(getByTestId('perps-market-detail-page')).toBeInTheDocument(); // Should display "TSLA-USD" with the stripped display name expect(getByText('TSLA-USD')).toBeInTheDocument(); }); - it('displays position section when user has a position', () => { + it('displays position section when user has a position', async () => { const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); // ETH has a mock position expect(getByText(messages.perpsPosition.message)).toBeInTheDocument(); }); - it('displays position P&L', () => { + it('displays position P&L', async () => { const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); // Check for P&L label expect(getByText(messages.perpsPnl.message)).toBeInTheDocument(); }); - it('displays position details section', () => { + it('displays position details section', async () => { const store = mockStore(createMockState(true)); - const { getByText, getAllByText } = renderWithProvider( - , - store, - ); + const { getByText, getAllByText } = await renderPage(store); expect(getByText(messages.perpsDetails.message)).toBeInTheDocument(); expect(getByText(messages.perpsDirection.message)).toBeInTheDocument(); @@ -469,46 +501,37 @@ describe('PerpsMarketDetailPage', () => { ).toBeGreaterThanOrEqual(1); }); - it('displays stats section', () => { + it('displays stats section', async () => { const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); expect(getByText(messages.perpsStats.message)).toBeInTheDocument(); expect(getByText(messages.perps24hVolume.message)).toBeInTheDocument(); }); - it('displays recent activity section', () => { + it('displays recent activity section', async () => { const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); expect( getByText(messages.perpsRecentActivity.message), ).toBeInTheDocument(); }); - it('displays learn section', () => { + it('displays learn section', async () => { const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); expect(getByText(messages.perpsLearnBasics.message)).toBeInTheDocument(); }); - it('opens Modify menu with Add margin, Remove margin, and Reverse position when Modify button is clicked', () => { + it('opens Modify menu with Add margin, Remove margin, and Reverse position when Modify button is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-modify-cta-button')); @@ -533,10 +556,10 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('opens Margin menu with Add margin and Remove margin when Margin card is clicked', () => { + it('opens Margin menu with Add margin and Remove margin when Margin card is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-margin-card')); expect(screen.getByTestId('perps-margin-menu')).toBeInTheDocument(); @@ -548,10 +571,10 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('opens Close position modal when Close button is clicked', () => { + it('opens Close position modal when Close button is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-close-cta-button')); expect( @@ -562,10 +585,10 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('navigates to order entry in modify mode when Add exposure is clicked', () => { + it('navigates to order entry in modify mode when Add exposure is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-modify-cta-button')); fireEvent.click(screen.getByTestId('perps-modify-menu-add-exposure')); @@ -581,10 +604,10 @@ describe('PerpsMarketDetailPage', () => { ); }); - it('navigates to order entry in close mode when Reduce exposure is clicked', () => { + it('navigates to order entry in close mode when Reduce exposure is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-modify-cta-button')); fireEvent.click(screen.getByTestId('perps-modify-menu-reduce-exposure')); @@ -597,10 +620,10 @@ describe('PerpsMarketDetailPage', () => { ); }); - it('opens Reverse position modal when Reverse position is clicked', () => { + it('opens Reverse position modal when Reverse position is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-modify-cta-button')); fireEvent.click(screen.getByTestId('perps-modify-menu-reverse-position')); @@ -610,10 +633,10 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('opens Add margin modal from Margin menu', () => { + it('opens Add margin modal from Margin menu', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-margin-card')); fireEvent.click(screen.getByTestId('perps-margin-menu-add')); @@ -621,10 +644,10 @@ describe('PerpsMarketDetailPage', () => { expect(screen.getByTestId('perps-add-margin-modal')).toBeInTheDocument(); }); - it('opens Remove margin modal from Margin menu', () => { + it('opens Remove margin modal from Margin menu', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-margin-card')); fireEvent.click(screen.getByTestId('perps-margin-menu-remove')); @@ -634,32 +657,32 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('displays Close Long button text for long position', () => { + it('displays Close Long button text for long position', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect( screen.getByText(messages.perpsCloseLong.message), ).toBeInTheDocument(); }); - it('displays Close Short button text for short position', () => { + it('displays Close Short button text for short position', async () => { mockUseParams.mockReturnValue({ symbol: 'BTC' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect( screen.getByText(messages.perpsCloseShort.message), ).toBeInTheDocument(); }); - it('shows short-specific descriptions in Modify menu for short position', () => { + it('shows short-specific descriptions in Modify menu for short position', async () => { mockUseParams.mockReturnValue({ symbol: 'BTC' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-modify-cta-button')); @@ -674,20 +697,20 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('displays disclaimer text', () => { + it('displays disclaimer text', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect( screen.getByText(messages.perpsDisclaimer.message), ).toBeInTheDocument(); }); - it('opens TP/SL modal when Auto Close row is clicked', () => { + it('opens TP/SL modal when Auto Close row is clicked', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-auto-close-row')); expect( @@ -698,9 +721,9 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); - it('populates TP price from preset button for long position', () => { + it('populates TP price from preset button for long position', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByText(messages.perpsAutoClose.message)); @@ -714,9 +737,9 @@ describe('PerpsMarketDetailPage', () => { expect(screen.getByDisplayValue('3,562.50')).toBeInTheDocument(); }); - it('populates SL price from preset button for long position', () => { + it('populates SL price from preset button for long position', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByText(messages.perpsAutoClose.message)); @@ -730,11 +753,11 @@ describe('PerpsMarketDetailPage', () => { expect(screen.getByDisplayValue('2,137.50')).toBeInTheDocument(); }); - it('populates TP price from preset button for short position', () => { + it('populates TP price from preset button for short position', async () => { // BTC is short (size=-0.5), entry = 45,000 mockUseParams.mockReturnValue({ symbol: 'BTC' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByText(messages.perpsAutoClose.message)); @@ -745,11 +768,11 @@ describe('PerpsMarketDetailPage', () => { expect(screen.getByDisplayValue('40,500.00')).toBeInTheDocument(); }); - it('populates SL price from preset button for short position', () => { + it('populates SL price from preset button for short position', async () => { // BTC is short (size=-0.5), entry = 45,000 mockUseParams.mockReturnValue({ symbol: 'BTC' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByText(messages.perpsAutoClose.message)); @@ -762,7 +785,7 @@ describe('PerpsMarketDetailPage', () => { it('shows TP/SL success toast without in-progress toast when saving', async () => { const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByText(messages.perpsAutoClose.message)); @@ -804,7 +827,7 @@ describe('PerpsMarketDetailPage', () => { }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByText(messages.perpsAutoClose.message)); @@ -825,11 +848,11 @@ describe('PerpsMarketDetailPage', () => { }); describe('when user has no position on the viewed market', () => { - it('shows Long and Short trade buttons instead of Modify/Close', () => { + it('shows Long and Short trade buttons instead of Modify/Close', async () => { mockUseParams.mockReturnValue({ symbol: 'xyz:AAPL' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect(screen.getByTestId('perps-trade-cta-buttons')).toBeInTheDocument(); expect(screen.getByTestId('perps-long-cta-button')).toBeInTheDocument(); @@ -842,11 +865,11 @@ describe('PerpsMarketDetailPage', () => { ).not.toBeInTheDocument(); }); - it('navigates to order entry when Long button is clicked', () => { + it('navigates to order entry when Long button is clicked', async () => { mockUseParams.mockReturnValue({ symbol: 'xyz:AAPL' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-long-cta-button')); @@ -858,11 +881,11 @@ describe('PerpsMarketDetailPage', () => { ); }); - it('navigates to order entry when Short button is clicked', () => { + it('navigates to order entry when Short button is clicked', async () => { mockUseParams.mockReturnValue({ symbol: 'xyz:AAPL' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); fireEvent.click(screen.getByTestId('perps-short-cta-button')); @@ -874,11 +897,11 @@ describe('PerpsMarketDetailPage', () => { ); }); - it('does not render position section', () => { + it('does not render position section', async () => { mockUseParams.mockReturnValue({ symbol: 'xyz:AAPL' }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect( screen.queryByText(messages.perpsPosition.message), @@ -887,42 +910,33 @@ describe('PerpsMarketDetailPage', () => { }); describe('when market is not found', () => { - it('renders error state for unknown market', () => { + it('renders error state for unknown market', async () => { mockUseParams.mockReturnValue({ symbol: 'UNKNOWN_MARKET_XYZ' }); const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); expect( getByText(messages.perpsMarketNotFound.message), ).toBeInTheDocument(); }); - it('displays the unknown market symbol in error message', () => { + it('displays the unknown market symbol in error message', async () => { mockUseParams.mockReturnValue({ symbol: 'NONEXISTENT' }); const store = mockStore(createMockState(true)); - const { getByText } = renderWithProvider( - , - store, - ); + const { getByText } = await renderPage(store); expect( getByText(/The market "NONEXISTENT" could not be found/u), ).toBeInTheDocument(); }); - it('displays back button on error state', () => { + it('displays back button on error state', async () => { mockUseParams.mockReturnValue({ symbol: 'UNKNOWN' }); const store = mockStore(createMockState(true)); - const { getByTestId } = renderWithProvider( - , - store, - ); + const { getByTestId } = await renderPage(store); expect( getByTestId('perps-market-detail-back-button'), @@ -931,10 +945,10 @@ describe('PerpsMarketDetailPage', () => { }); describe('when perps feature is disabled', () => { - it('redirects to home when perps is disabled', () => { + it('redirects to home when perps is disabled', async () => { const store = mockStore(createMockState(false)); - renderWithProvider(, store); + await renderPage(store); expect(mockNavigateComponent).toHaveBeenCalledWith( expect.objectContaining({ @@ -946,11 +960,11 @@ describe('PerpsMarketDetailPage', () => { }); describe('when no symbol is provided', () => { - it('redirects to home when symbol is undefined', () => { + it('redirects to home when symbol is undefined', async () => { mockUseParams.mockReturnValue({ symbol: undefined }); const store = mockStore(createMockState(true)); - renderWithProvider(, store); + await renderPage(store); expect(mockNavigateComponent).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/ui/pages/perps/perps-order-entry-page.test.tsx b/ui/pages/perps/perps-order-entry-page.test.tsx index b01b4f326cf2..598796e87dce 100644 --- a/ui/pages/perps/perps-order-entry-page.test.tsx +++ b/ui/pages/perps/perps-order-entry-page.test.tsx @@ -19,6 +19,67 @@ import { } from '../../components/app/perps/mocks'; import PerpsOrderEntryPage from './perps-order-entry-page'; +jest.mock('@metamask/perps-controller', () => ({ + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, +})); + jest.mock('../../hooks/perps/usePerpsEligibility', () => ({ usePerpsEligibility: () => ({ isEligible: true }), })); @@ -577,11 +638,10 @@ describe('PerpsOrderEntryPage', () => { fireEvent.click(screen.getByTestId('submit-order-button')); }); - expect(screen.queryByText('Insufficient margin')).not.toBeInTheDocument(); expect(mockHidePerpsToast).toHaveBeenCalledTimes(1); expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastOrderFailed', - description: 'Insufficient margin', + description: 'Insufficient margin to place this order.', }); }); @@ -606,11 +666,10 @@ describe('PerpsOrderEntryPage', () => { fireEvent.click(screen.getByTestId('submit-order-button')); }); - expect(screen.queryByText('Network error')).not.toBeInTheDocument(); expect(mockHidePerpsToast).toHaveBeenCalledTimes(1); expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastOrderFailed', - description: 'Network error', + description: 'A network error occurred. Please try again.', }); }); @@ -1093,11 +1152,10 @@ describe('PerpsOrderEntryPage', () => { fireEvent.click(screen.getByTestId('submit-order-button')); }); - expect(screen.queryByText('Close failed')).not.toBeInTheDocument(); expect(mockHidePerpsToast).toHaveBeenCalledTimes(1); expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastCloseFailed', - description: 'Close failed', + description: "We couldn't load this page.", }); }); @@ -1124,11 +1182,10 @@ describe('PerpsOrderEntryPage', () => { fireEvent.click(screen.getByTestId('submit-order-button')); }); - expect(screen.queryByText('TPSL update failed')).not.toBeInTheDocument(); expect(mockHidePerpsToast).not.toHaveBeenCalled(); expect(mockReplacePerpsToastByKey).toHaveBeenCalledWith({ key: 'perpsToastUpdateFailed', - description: 'TPSL update failed', + description: "We couldn't load this page.", }); }); diff --git a/ui/pages/perps/perps-withdraw-page.test.tsx b/ui/pages/perps/perps-withdraw-page.test.tsx index a8103f02a793..56bd4105a0b1 100644 --- a/ui/pages/perps/perps-withdraw-page.test.tsx +++ b/ui/pages/perps/perps-withdraw-page.test.tsx @@ -23,6 +23,64 @@ jest.mock('@metamask/perps-controller', () => ({ DefaultMinAmount: '1.01', DefaultFeeAmount: 1, }, + PERPS_ERROR_CODES: { + CLIENT_NOT_INITIALIZED: 'CLIENT_NOT_INITIALIZED', + CLIENT_REINITIALIZING: 'CLIENT_REINITIALIZING', + PROVIDER_NOT_AVAILABLE: 'PROVIDER_NOT_AVAILABLE', + TOKEN_NOT_SUPPORTED: 'TOKEN_NOT_SUPPORTED', + BRIDGE_CONTRACT_NOT_FOUND: 'BRIDGE_CONTRACT_NOT_FOUND', + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + POSITIONS_FAILED: 'POSITIONS_FAILED', + ACCOUNT_STATE_FAILED: 'ACCOUNT_STATE_FAILED', + MARKETS_FAILED: 'MARKETS_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', + ORDER_LEVERAGE_REDUCTION_FAILED: 'ORDER_LEVERAGE_REDUCTION_FAILED', + IOC_CANCEL: 'IOC_CANCEL', + CONNECTION_TIMEOUT: 'CONNECTION_TIMEOUT', + WITHDRAW_ASSET_ID_REQUIRED: 'WITHDRAW_ASSET_ID_REQUIRED', + WITHDRAW_AMOUNT_REQUIRED: 'WITHDRAW_AMOUNT_REQUIRED', + WITHDRAW_AMOUNT_POSITIVE: 'WITHDRAW_AMOUNT_POSITIVE', + WITHDRAW_INVALID_DESTINATION: 'WITHDRAW_INVALID_DESTINATION', + WITHDRAW_ASSET_NOT_SUPPORTED: 'WITHDRAW_ASSET_NOT_SUPPORTED', + WITHDRAW_INSUFFICIENT_BALANCE: 'WITHDRAW_INSUFFICIENT_BALANCE', + DEPOSIT_ASSET_ID_REQUIRED: 'DEPOSIT_ASSET_ID_REQUIRED', + DEPOSIT_AMOUNT_REQUIRED: 'DEPOSIT_AMOUNT_REQUIRED', + DEPOSIT_AMOUNT_POSITIVE: 'DEPOSIT_AMOUNT_POSITIVE', + DEPOSIT_MINIMUM_AMOUNT: 'DEPOSIT_MINIMUM_AMOUNT', + ORDER_COIN_REQUIRED: 'ORDER_COIN_REQUIRED', + ORDER_LIMIT_PRICE_REQUIRED: 'ORDER_LIMIT_PRICE_REQUIRED', + ORDER_PRICE_POSITIVE: 'ORDER_PRICE_POSITIVE', + ORDER_UNKNOWN_COIN: 'ORDER_UNKNOWN_COIN', + ORDER_SIZE_POSITIVE: 'ORDER_SIZE_POSITIVE', + ORDER_PRICE_REQUIRED: 'ORDER_PRICE_REQUIRED', + ORDER_SIZE_MIN: 'ORDER_SIZE_MIN', + ORDER_LEVERAGE_INVALID: 'ORDER_LEVERAGE_INVALID', + ORDER_LEVERAGE_BELOW_POSITION: 'ORDER_LEVERAGE_BELOW_POSITION', + ORDER_MAX_VALUE_EXCEEDED: 'ORDER_MAX_VALUE_EXCEEDED', + EXCHANGE_CLIENT_NOT_AVAILABLE: 'EXCHANGE_CLIENT_NOT_AVAILABLE', + INFO_CLIENT_NOT_AVAILABLE: 'INFO_CLIENT_NOT_AVAILABLE', + SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', + NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + KEYRING_LOCKED: 'KEYRING_LOCKED', + INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', + TRANSFER_FAILED: 'TRANSFER_FAILED', + SWAP_FAILED: 'SWAP_FAILED', + SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', + PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', + BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', + INSUFFICIENT_MARGIN: 'INSUFFICIENT_MARGIN', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + REDUCE_ONLY_VIOLATION: 'REDUCE_ONLY_VIOLATION', + POSITION_WOULD_FLIP: 'POSITION_WOULD_FLIP', + MARGIN_ADJUSTMENT_FAILED: 'MARGIN_ADJUSTMENT_FAILED', + TPSL_UPDATE_FAILED: 'TPSL_UPDATE_FAILED', + ORDER_REJECTED: 'ORDER_REJECTED', + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + NETWORK_ERROR: 'NETWORK_ERROR', + }, })); const mockNavigate = jest.fn(); From a8d13cbc559b3cd75cc9cdfdf5641de19c073665 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 08:35:49 -1000 Subject: [PATCH 15/22] fix: merge conflicts --- ui/pages/perps/perps-market-detail-page.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/pages/perps/perps-market-detail-page.test.tsx b/ui/pages/perps/perps-market-detail-page.test.tsx index 849505939782..a7e0a123d0a9 100644 --- a/ui/pages/perps/perps-market-detail-page.test.tsx +++ b/ui/pages/perps/perps-market-detail-page.test.tsx @@ -529,9 +529,6 @@ describe('PerpsMarketDetailPage', () => { ).toBeInTheDocument(); }); -<<<<<<< HEAD - it('displays learn section', async () => { -======= it('does not show View All button when there are no fills', () => { const store = mockStore(createMockState(true)); @@ -577,7 +574,6 @@ describe('PerpsMarketDetailPage', () => { }); it('displays learn section', () => { ->>>>>>> main const store = mockStore(createMockState(true)); const { getByText } = await renderPage(store); From b0b82e2f34f39666cfa27ba8fc54d7c0733005c1 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 09:24:13 -1000 Subject: [PATCH 16/22] fix: barrel import jest issues --- .../multichain/account-overview/account-overview-tabs.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/components/multichain/account-overview/account-overview-tabs.tsx b/ui/components/multichain/account-overview/account-overview-tabs.tsx index 5638448092ca..417ee19a594a 100644 --- a/ui/components/multichain/account-overview/account-overview-tabs.tsx +++ b/ui/components/multichain/account-overview/account-overview-tabs.tsx @@ -30,11 +30,9 @@ import { import AssetList from '../../app/assets/asset-list'; import DeFiTab from '../../app/assets/defi-list/defi-tab'; import NftsTab from '../../app/assets/nfts/nfts-tab'; -import { - PerpsToastProvider, - PerpsView, - PerpsViewStreamBoundary, -} from '../../app/perps'; +import { PerpsView } from '../../app/perps/perps-view'; +import { PerpsViewStreamBoundary } from '../../app/perps/perps-view-stream-boundary'; +import { PerpsToastProvider } from '../../app/perps/perps-toast'; import { Tab, Tabs } from '../../ui/tabs'; import { useTokenBalances } from '../../../hooks/useTokenBalances'; import { ActivityList } from '../activity-v2/activity-list'; From fce42bb9b651ac13ca9a65f6b5f1d642e89c143d Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 09:54:51 -1000 Subject: [PATCH 17/22] fix: async unit test --- ui/pages/perps/perps-market-detail-page.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/pages/perps/perps-market-detail-page.test.tsx b/ui/pages/perps/perps-market-detail-page.test.tsx index a7e0a123d0a9..4f984f4baf11 100644 --- a/ui/pages/perps/perps-market-detail-page.test.tsx +++ b/ui/pages/perps/perps-market-detail-page.test.tsx @@ -573,7 +573,7 @@ describe('PerpsMarketDetailPage', () => { expect(mockUseNavigate).toHaveBeenCalledWith(PERPS_ACTIVITY_ROUTE); }); - it('displays learn section', () => { + it('displays learn section', async () => { const store = mockStore(createMockState(true)); const { getByText } = await renderPage(store); From c379bd938bef979460c1e617c8d4a4d0b0883398 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 10:15:54 -1000 Subject: [PATCH 18/22] fix: locale violation --- .../perps/reverse-position/reverse-position-modal.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx b/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx index fc89b466c961..eacdae8d891b 100644 --- a/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx +++ b/ui/components/app/perps/reverse-position/reverse-position-modal.test.tsx @@ -312,7 +312,7 @@ describe('ReversePositionModal', () => { await waitFor(() => { expect( - screen.getByText('Insufficient margin to place this order.'), + screen.getByText(messages.perpsInsufficientMargin.message), ).toBeInTheDocument(); }); }); @@ -385,7 +385,7 @@ describe('ReversePositionModal', () => { await waitFor(() => { expect( - screen.getByText('A network error occurred. Please try again.'), + screen.getByText(messages.perpsNetworkError.message), ).toBeInTheDocument(); }); }); From dca9ce275a059f47e83759e9c4449bfc694c141e Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 11:09:57 -1000 Subject: [PATCH 19/22] chore: bugbot --- .../app/perps/utils/translate-perps-error.test.ts | 10 ++++++++++ .../app/perps/utils/translate-perps-error.ts | 15 +++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ui/components/app/perps/utils/translate-perps-error.test.ts b/ui/components/app/perps/utils/translate-perps-error.test.ts index a188f07015f0..7c5fee72b44d 100644 --- a/ui/components/app/perps/utils/translate-perps-error.test.ts +++ b/ui/components/app/perps/utils/translate-perps-error.test.ts @@ -224,6 +224,16 @@ describe('translatePerpsError', () => { expect(translatePerpsError(error, mockT)).toBe('[perpsWithdrawFailed]'); }); + it('resolves an error code used as the message (e.g. WithdrawResult.error)', () => { + const error = new Error('WITHDRAW_INSUFFICIENT_BALANCE'); + expect(translatePerpsError(error, mockT)).toBe('[perpsWithdrawInsufficient]'); + }); + + it('resolves WITHDRAW_FAILED code string used as message', () => { + const error = new Error('WITHDRAW_FAILED'); + expect(translatePerpsError(error, mockT)).toBe('[perpsWithdrawFailed]'); + }); + it('falls back to pattern matching when no code property is present', () => { const error = new Error('Order rejected by the exchange'); expect(translatePerpsError(error, mockT)).toBe('[perpsOrderRejected]'); diff --git a/ui/components/app/perps/utils/translate-perps-error.ts b/ui/components/app/perps/utils/translate-perps-error.ts index fc4ca4600046..3d2d4080e814 100644 --- a/ui/components/app/perps/utils/translate-perps-error.ts +++ b/ui/components/app/perps/utils/translate-perps-error.ts @@ -168,8 +168,9 @@ export const API_ERROR_PATTERNS: { * Resolution order: * 1. If the error has a `code` property matching a known PerpsErrorCode, use * `ERROR_CODE_TO_I18N_KEY` to look up the message key. - * 2. If the error message matches an API error pattern, use the mapped code's key. - * 3. Fall back to `null` (caller should show a generic fallback). + * 2. If the error message itself is a known PerpsErrorCode string, use it directly. + * 3. If the error message matches an API error pattern, use the mapped code's key. + * 4. Fall back to `null` (caller should show a generic fallback). * * @param error - The unknown thrown value. * @param t - The extension i18n translation function from `useI18nContext()`. @@ -193,8 +194,14 @@ export function translatePerpsError( return t(i18nKey); } - // 2. Pattern match against raw error message + // 2. Message-as-code lookup — handles plain strings that ARE error codes + // (e.g. WithdrawResult.error wrapped in `new Error(code)`) const message = errorObj?.message ?? ''; + if (message && message in ERROR_CODE_TO_I18N_KEY) { + return t(ERROR_CODE_TO_I18N_KEY[message as PerpsErrorCode]); + } + + // 3. Pattern match against raw error message if (message) { for (const { pattern, code } of API_ERROR_PATTERNS) { if (pattern.test(message)) { @@ -204,7 +211,7 @@ export function translatePerpsError( } } - // 3. No match + // 4. No match return null; } From 8b1107f49751711ebfd0acf51a36765b1ae957ba Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Wed, 8 Apr 2026 11:26:02 -1000 Subject: [PATCH 20/22] fix: lint --- ui/components/app/perps/utils/translate-perps-error.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/components/app/perps/utils/translate-perps-error.test.ts b/ui/components/app/perps/utils/translate-perps-error.test.ts index 7c5fee72b44d..63b7127fcefe 100644 --- a/ui/components/app/perps/utils/translate-perps-error.test.ts +++ b/ui/components/app/perps/utils/translate-perps-error.test.ts @@ -226,7 +226,9 @@ describe('translatePerpsError', () => { it('resolves an error code used as the message (e.g. WithdrawResult.error)', () => { const error = new Error('WITHDRAW_INSUFFICIENT_BALANCE'); - expect(translatePerpsError(error, mockT)).toBe('[perpsWithdrawInsufficient]'); + expect(translatePerpsError(error, mockT)).toBe( + '[perpsWithdrawInsufficient]', + ); }); it('resolves WITHDRAW_FAILED code string used as message', () => { From c9895734723f2b5d95ac5df725f194306aaf15fe Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 9 Apr 2026 08:42:56 -1000 Subject: [PATCH 21/22] fix: locale --- app/_locales/en/messages.json | 3 --- app/_locales/en_GB/messages.json | 3 --- 2 files changed, 6 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index d2a9a6a4a32a..1363cc58f4bc 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5824,9 +5824,6 @@ "perpsSortByVolume": { "message": "Volume" }, - "perpsSortFundingRateHighToLow": { - "message": "Funding rate: high to low" - }, "perpsStartNewTrade": { "message": "Start a new trade" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index d2a9a6a4a32a..1363cc58f4bc 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5824,9 +5824,6 @@ "perpsSortByVolume": { "message": "Volume" }, - "perpsSortFundingRateHighToLow": { - "message": "Funding rate: high to low" - }, "perpsStartNewTrade": { "message": "Start a new trade" }, From 35298ff56ddc38ad4d67ca53630f4fb91653c2c9 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 9 Apr 2026 10:43:10 -1000 Subject: [PATCH 22/22] fix: add usePerpsLifecycle to perps stream boundary --- ui/components/app/perps/perps-view-stream-boundary.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/components/app/perps/perps-view-stream-boundary.tsx b/ui/components/app/perps/perps-view-stream-boundary.tsx index 320b7eeac381..2021f8efbb22 100644 --- a/ui/components/app/perps/perps-view-stream-boundary.tsx +++ b/ui/components/app/perps/perps-view-stream-boundary.tsx @@ -1,5 +1,6 @@ import React, { type ReactNode } from 'react'; import { usePerpsViewActive } from '../../../hooks/perps/stream/usePerpsViewActive'; +import { usePerpsLifecycleBreadcrumbs } from '../../../hooks/perps/usePerpsLifecycleBreadcrumbs'; type PerpsViewStreamBoundaryProps = Readonly<{ children: ReactNode; @@ -15,5 +16,6 @@ export function PerpsViewStreamBoundary({ children, }: PerpsViewStreamBoundaryProps) { usePerpsViewActive('PerpsViewStreamBoundary'); + usePerpsLifecycleBreadcrumbs(); return <>{children}; }