diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 19ba331003..6798471285 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -92,9 +92,11 @@ export { createTimeToFullDisplay, createTimeToInitialDisplay, wrapExpoRouter, + wrapExpoImage, + wrapExpoAsset, } from './tracing'; -export type { TimeToDisplayProps, ExpoRouter } from './tracing'; +export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset, ExpoAssetInstance } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts new file mode 100644 index 0000000000..9030237af9 --- /dev/null +++ b/packages/core/src/js/tracing/expoAsset.ts @@ -0,0 +1,118 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; +import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from './origin'; + +/** + * Internal interface for expo-asset's Asset instance. + * We define this to avoid a hard dependency on expo-asset. + */ +export interface ExpoAssetInstance { + name: string; + type: string; + hash: string | null; + uri: string; + localUri: string | null; + width: number | null; + height: number | null; + downloaded: boolean; + downloadAsync(): Promise; +} + +/** + * Represents the expo-asset `Asset` class with its static methods. + * We only describe the methods that we instrument. + */ +export interface ExpoAsset { + loadAsync(moduleId: number | number[] | string | string[]): Promise; + fromModule(virtualAssetModule: number | string): ExpoAssetInstance; +} + +/** + * Wraps expo-asset's `Asset` class to add automated performance monitoring. + * + * This function instruments `Asset.loadAsync` static method + * to create performance spans that measure how long asset loading takes. + * + * @param assetClass - The `Asset` class from `expo-asset` + * @returns The same class with instrumented static methods + * + * @example + * ```typescript + * import { Asset } from 'expo-asset'; + * import * as Sentry from '@sentry/react-native'; + * + * Sentry.wrapExpoAsset(Asset); + * ``` + */ +export function wrapExpoAsset(assetClass: T): T { + if (!assetClass) { + return assetClass; + } + + if ((assetClass as T & { __sentryWrapped?: boolean }).__sentryWrapped) { + return assetClass; + } + + wrapLoadAsync(assetClass); + + (assetClass as T & { __sentryWrapped?: boolean }).__sentryWrapped = true; + + return assetClass; +} + +function wrapLoadAsync(assetClass: T): void { + if (!assetClass.loadAsync) { + return; + } + + const originalLoadAsync = assetClass.loadAsync.bind(assetClass); + + assetClass.loadAsync = ((moduleId: number | number[] | string | string[]): Promise => { + const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId]; + const assetCount = moduleIds.length; + const description = describeModuleIds(moduleIds); + + const span = startInactiveSpan({ + op: 'resource.asset', + name: `Asset load ${description}`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': assetCount, + }, + }); + + return originalLoadAsync(moduleId) + .then(result => { + span?.setStatus({ code: SPAN_STATUS_OK }); + span?.end(); + return result; + }) + .catch((error: unknown) => { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + }); + }) as T['loadAsync']; +} + +function describeModuleIds(moduleIds: (number | string)[]): string { + if (moduleIds.length === 1) { + const id = moduleIds[0]; + if (typeof id === 'string') { + return describeUrl(id); + } + return `asset #${id}`; + } + return `${moduleIds.length} assets`; +} + +function describeUrl(url: string): string { + try { + // Remove query string and fragment + const withoutQuery = url.split('?')[0] || url; + const withoutFragment = withoutQuery.split('#')[0] || withoutQuery; + const filename = withoutFragment.split('/').pop(); + return filename || url; + } catch { + return url; + } +} diff --git a/packages/core/src/js/tracing/expoImage.ts b/packages/core/src/js/tracing/expoImage.ts new file mode 100644 index 0000000000..f9a10c2268 --- /dev/null +++ b/packages/core/src/js/tracing/expoImage.ts @@ -0,0 +1,184 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; +import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin'; + +/** + * Internal interface for expo-image's ImageSource. + * We define this to avoid a hard dependency on expo-image. + */ +interface ExpoImageSource { + uri?: string; + headers?: Record; + width?: number | null; + height?: number | null; + cacheKey?: string; +} + +/** + * Internal interface for expo-image's ImageLoadOptions. + * We define this to avoid a hard dependency on expo-image. + */ +interface ExpoImageLoadOptions { + maxWidth?: number; + maxHeight?: number; + onError?(error: Error, retry: () => void): void; +} + +/** + * Internal interface for expo-image's ImageRef. + * We define this to avoid a hard dependency on expo-image. + */ +interface ExpoImageRef { + readonly width: number; + readonly height: number; + readonly scale: number; + readonly mediaType: string | null; + readonly isAnimated?: boolean; +} + +/** + * Represents the expo-image `Image` class with its static methods. + * We only describe the methods that we instrument. + */ +export interface ExpoImage { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prefetch(urls: string | string[], cachePolicyOrOptions?: any): Promise; + loadAsync(source: ExpoImageSource | string | number, options?: ExpoImageLoadOptions): Promise; + clearMemoryCache?(): Promise; + clearDiskCache?(): Promise; +} + +/** + * Wraps expo-image's `Image` class to add automated performance monitoring. + * + * This function instruments `Image.prefetch` and `Image.loadAsync` static methods + * to create performance spans that measure how long image prefetching and loading take. + * + * @param imageClass - The `Image` class from `expo-image` + * @returns The same class with instrumented static methods + * + * @example + * ```typescript + * import { Image } from 'expo-image'; + * import * as Sentry from '@sentry/react-native'; + * + * Sentry.wrapExpoImage(Image); + * ``` + */ +export function wrapExpoImage(imageClass: T): T { + if (!imageClass) { + return imageClass; + } + + if ((imageClass as T & { __sentryWrapped?: boolean }).__sentryWrapped) { + return imageClass; + } + + wrapPrefetch(imageClass); + wrapLoadAsync(imageClass); + + (imageClass as T & { __sentryWrapped?: boolean }).__sentryWrapped = true; + + return imageClass; +} + +function wrapPrefetch(imageClass: T): void { + if (!imageClass.prefetch) { + return; + } + + const originalPrefetch = imageClass.prefetch.bind(imageClass); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + imageClass.prefetch = ((urls: string | string[], cachePolicyOrOptions?: any): Promise => { + const urlList = Array.isArray(urls) ? urls : [urls]; + const urlCount = urlList.length; + const firstUrl = urlList[0] || 'unknown'; + const description = urlCount === 1 ? describeUrl(firstUrl) : `${urlCount} images`; + + const span = startInactiveSpan({ + op: 'resource.image.prefetch', + name: `Image prefetch ${description}`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url_count': urlCount, + ...(urlCount === 1 ? { 'image.url': firstUrl } : undefined), + }, + }); + + return originalPrefetch(urls, cachePolicyOrOptions) + .then(result => { + span?.setStatus({ code: SPAN_STATUS_OK }); + span?.end(); + return result; + }) + .catch((error: unknown) => { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + }); + }) as T['prefetch']; +} + +function wrapLoadAsync(imageClass: T): void { + if (!imageClass.loadAsync) { + return; + } + + const originalLoadAsync = imageClass.loadAsync.bind(imageClass); + + imageClass.loadAsync = (( + source: ExpoImageSource | string | number, + options?: ExpoImageLoadOptions, + ): Promise => { + const description = describeSource(source); + + const imageUrl = + typeof source === 'string' ? source : typeof source === 'object' && source.uri ? source.uri : undefined; + + const span = startInactiveSpan({ + op: 'resource.image.load', + name: `Image load ${description}`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + ...(imageUrl ? { 'image.url': imageUrl } : undefined), + }, + }); + + return originalLoadAsync(source, options) + .then(result => { + span?.setStatus({ code: SPAN_STATUS_OK }); + span?.end(); + return result; + }) + .catch((error: unknown) => { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + }); + }) as T['loadAsync']; +} + +function describeUrl(url: string): string { + try { + // Remove query string and fragment + const withoutQuery = url.split('?')[0] || url; + const withoutFragment = withoutQuery.split('#')[0] || withoutQuery; + const filename = withoutFragment.split('/').pop(); + return filename || url; + } catch { + return url; + } +} + +function describeSource(source: ExpoImageSource | string | number): string { + if (typeof source === 'number') { + return `asset #${source}`; + } + if (typeof source === 'string') { + return describeUrl(source); + } + if (source.uri) { + return describeUrl(source.uri); + } + return 'unknown source'; +} diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index 4a0e3f27d2..9e1db904a4 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -12,6 +12,12 @@ export { reactNativeNavigationIntegration } from './reactnativenavigation'; export { wrapExpoRouter } from './expoRouter'; export type { ExpoRouter } from './expoRouter'; +export { wrapExpoImage } from './expoImage'; +export type { ExpoImage } from './expoImage'; + +export { wrapExpoAsset } from './expoAsset'; +export type { ExpoAsset, ExpoAssetInstance } from './expoAsset'; + export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; diff --git a/packages/core/src/js/tracing/origin.ts b/packages/core/src/js/tracing/origin.ts index 858dbfa2cc..3b2fd4ca32 100644 --- a/packages/core/src/js/tracing/origin.ts +++ b/packages/core/src/js/tracing/origin.ts @@ -12,3 +12,5 @@ export const SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY = 'auto.ui.time_to_display'; export const SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY = 'manual.ui.time_to_display'; export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH = 'auto.expo_router.prefetch'; +export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE = 'auto.resource.expo_image'; +export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET = 'auto.resource.expo_asset'; diff --git a/packages/core/test/tracing/expoAsset.test.ts b/packages/core/test/tracing/expoAsset.test.ts new file mode 100644 index 0000000000..f9904849ff --- /dev/null +++ b/packages/core/test/tracing/expoAsset.test.ts @@ -0,0 +1,203 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { type ExpoAsset, wrapExpoAsset } from '../../src/js/tracing'; +import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from '../../src/js/tracing/origin'; + +const mockStartInactiveSpan = jest.fn(); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), + }; +}); + +describe('wrapExpoAsset', () => { + let mockSpan: { + setStatus: jest.Mock; + end: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSpan = { + setStatus: jest.fn(), + end: jest.fn(), + }; + mockStartInactiveSpan.mockReturnValue(mockSpan); + }); + + it('returns the class unchanged if null or undefined', () => { + expect(wrapExpoAsset(null as unknown as ExpoAsset)).toBeNull(); + expect(wrapExpoAsset(undefined as unknown as ExpoAsset)).toBeUndefined(); + }); + + it('does not double-wrap the same class', () => { + const assetClass = { + loadAsync: jest.fn().mockResolvedValue([]), + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + const wrapped1 = wrapExpoAsset(assetClass); + const wrapped2 = wrapExpoAsset(wrapped1); + + expect(wrapped1).toBe(wrapped2); + }); + + describe('loadAsync', () => { + it('creates a span for loading a single numeric module ID', async () => { + const mockAsset = { name: 'icon', type: 'png', downloaded: true }; + const mockLoadAsync = jest.fn().mockResolvedValue([mockAsset]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + const result = await assetClass.loadAsync(42); + + expect(result).toEqual([mockAsset]); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load asset #42', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 1, + }, + }); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('creates a span for loading a single string URL', async () => { + const mockAsset = { name: 'photo', type: 'jpg', downloaded: true }; + const mockLoadAsync = jest.fn().mockResolvedValue([mockAsset]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync('https://example.com/photo.jpg'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load photo.jpg', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 1, + }, + }); + }); + + it('creates a span for loading multiple numeric module IDs', async () => { + const mockAssets = [ + { name: 'icon', type: 'png', downloaded: true }, + { name: 'splash', type: 'png', downloaded: true }, + { name: 'logo', type: 'svg', downloaded: true }, + ]; + const mockLoadAsync = jest.fn().mockResolvedValue(mockAssets); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync([1, 2, 3]); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load 3 assets', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 3, + }, + }); + }); + + it('creates a span for loading multiple string URLs', async () => { + const mockAssets = [ + { name: 'a', type: 'png', downloaded: true }, + { name: 'b', type: 'png', downloaded: true }, + ]; + const mockLoadAsync = jest.fn().mockResolvedValue(mockAssets); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync(['https://example.com/a.png', 'https://example.com/b.png']); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load 2 assets', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 2, + }, + }); + }); + + it('handles loadAsync failure', async () => { + const error = new Error('Asset not found'); + const mockLoadAsync = jest.fn().mockRejectedValue(error); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + + await expect(assetClass.loadAsync(99)).rejects.toThrow('Asset not found'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Asset not found', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('passes the original moduleId argument through', async () => { + const mockLoadAsync = jest.fn().mockResolvedValue([]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync([10, 20]); + + expect(mockLoadAsync).toHaveBeenCalledWith([10, 20]); + }); + + it('handles non-URL string gracefully', async () => { + const mockLoadAsync = jest.fn().mockResolvedValue([]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync('not-a-url'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Asset load not-a-url', + }), + ); + }); + }); + + it('preserves fromModule method', () => { + const mockFromModule = jest.fn(); + const assetClass = { + loadAsync: jest.fn().mockResolvedValue([]), + fromModule: mockFromModule, + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + + expect(assetClass.fromModule).toBe(mockFromModule); + }); +}); diff --git a/packages/core/test/tracing/expoImage.test.ts b/packages/core/test/tracing/expoImage.test.ts new file mode 100644 index 0000000000..d113bfc5bf --- /dev/null +++ b/packages/core/test/tracing/expoImage.test.ts @@ -0,0 +1,261 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { type ExpoImage, wrapExpoImage } from '../../src/js/tracing'; +import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from '../../src/js/tracing/origin'; + +const mockStartInactiveSpan = jest.fn(); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), + }; +}); + +describe('wrapExpoImage', () => { + let mockSpan: { + setStatus: jest.Mock; + end: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSpan = { + setStatus: jest.fn(), + end: jest.fn(), + }; + mockStartInactiveSpan.mockReturnValue(mockSpan); + }); + + it('returns the class unchanged if null or undefined', () => { + expect(wrapExpoImage(null as unknown as ExpoImage)).toBeNull(); + expect(wrapExpoImage(undefined as unknown as ExpoImage)).toBeUndefined(); + }); + + it('does not double-wrap the same class', () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn().mockResolvedValue({}) } as unknown as ExpoImage; + + const wrapped1 = wrapExpoImage(imageClass); + const wrapped2 = wrapExpoImage(wrapped1); + + expect(wrapped1).toBe(wrapped2); + }); + + describe('prefetch', () => { + it('creates a span for single URL prefetch', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://example.com/image.png'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.prefetch', + name: 'Image prefetch image.png', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url_count': 1, + 'image.url': 'https://example.com/image.png', + }, + }); + + expect(mockPrefetch).toHaveBeenCalledWith('https://example.com/image.png', undefined); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('creates a span for multiple URL prefetch', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const urls = ['https://example.com/a.png', 'https://example.com/b.png', 'https://example.com/c.png']; + await imageClass.prefetch(urls); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.prefetch', + name: 'Image prefetch 3 images', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url_count': 3, + }, + }); + }); + + it('passes cache policy option through', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://example.com/image.png', 'memory-disk'); + + expect(mockPrefetch).toHaveBeenCalledWith('https://example.com/image.png', 'memory-disk'); + }); + + it('handles prefetch failure', async () => { + const error = new Error('Network error'); + const mockPrefetch = jest.fn().mockRejectedValue(error); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + await expect(imageClass.prefetch('https://example.com/image.png')).rejects.toThrow('Network error'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Network error', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('handles URL without path correctly', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://example.com'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Image prefetch'), + }), + ); + }); + + it('handles non-URL string gracefully', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('not-a-url'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Image prefetch not-a-url', + }), + ); + }); + }); + + describe('loadAsync', () => { + it('creates a span for loading by URL string', async () => { + const mockResult = { width: 100, height: 100, scale: 1, mediaType: 'image/png' }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const result = await imageClass.loadAsync('https://example.com/photo.jpg'); + + expect(result).toBe(mockResult); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load photo.jpg', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url': 'https://example.com/photo.jpg', + }, + }); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('creates a span for loading by ImageSource object', async () => { + const mockResult = { width: 200, height: 200, scale: 2, mediaType: 'image/jpeg' }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const source = { uri: 'https://example.com/avatar.jpg', width: 200, height: 200 }; + await imageClass.loadAsync(source); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load avatar.jpg', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url': 'https://example.com/avatar.jpg', + }, + }); + }); + + it('creates a span for loading by module ID (number)', async () => { + const mockResult = { width: 50, height: 50, scale: 1, mediaType: null }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.loadAsync(42); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load asset #42', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + }, + }); + }); + + it('creates a span for ImageSource without uri', async () => { + const mockResult = { width: 10, height: 10, scale: 1, mediaType: null }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.loadAsync({ width: 10, height: 10 }); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load unknown source', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + }, + }); + }); + + it('handles loadAsync failure', async () => { + const error = new Error('Load failed'); + const mockLoadAsync = jest.fn().mockRejectedValue(error); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + await expect(imageClass.loadAsync('https://example.com/broken.png')).rejects.toThrow('Load failed'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Load failed', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('passes options through to original loadAsync', async () => { + const mockResult = { width: 100, height: 100, scale: 1, mediaType: null }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + const onError = jest.fn(); + + wrapExpoImage(imageClass); + await imageClass.loadAsync('https://example.com/img.png', { maxWidth: 800, onError }); + + expect(mockLoadAsync).toHaveBeenCalledWith('https://example.com/img.png', { maxWidth: 800, onError }); + }); + }); + + it('preserves other static methods', () => { + const mockClearMemoryCache = jest.fn().mockResolvedValue(true); + const mockClearDiskCache = jest.fn().mockResolvedValue(true); + const imageClass = { + prefetch: jest.fn().mockResolvedValue(true), + loadAsync: jest.fn().mockResolvedValue({}), + clearMemoryCache: mockClearMemoryCache, + clearDiskCache: mockClearDiskCache, + } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + expect((imageClass as ExpoImage & { clearMemoryCache: jest.Mock }).clearMemoryCache).toBe(mockClearMemoryCache); + expect((imageClass as ExpoImage & { clearDiskCache: jest.Mock }).clearDiskCache).toBe(mockClearDiskCache); + }); +});