diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fa9f0d..c32666da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- Breadcrumbs added on hints are now captured. ([#629](https://github.com/getsentry/sentry-capacitor/pull/629)) +- Event is enriched with all the Android context on the JS layer and you can filter/modify all the data in the `beforeSend`. ([#629](https://github.com/getsentry/sentry-capacitor/pull/629)) + ## 0.18.0 ### Features diff --git a/android/src/main/java/io/sentry/capacitor/CapSentryMapConverter.java b/android/src/main/java/io/sentry/capacitor/CapSentryMapConverter.java new file mode 100644 index 00000000..72261f06 --- /dev/null +++ b/android/src/main/java/io/sentry/capacitor/CapSentryMapConverter.java @@ -0,0 +1,137 @@ +package io.sentry.capacitor; + +import com.getcapacitor.JSArray; +import com.getcapacitor.JSObject; +import com.getcapacitor.PluginCall; +import org.json.JSONArray; +import org.json.JSONException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; + +public class CapSentryMapConverter { + public static final String NAME = "CapSentry.MapConverter"; + + private static final ILogger logger = new AndroidLogger(NAME); + + public static Object convertToWritable(Object serialized) { + if (serialized instanceof Map) { + JSObject writable = new JSObject(); + for (Map.Entry entry : ((Map) serialized).entrySet()) { + Object key = entry.getKey(); + Object value = entry.getValue(); + + if (key instanceof String) { + addValueToWritableMap(writable, (String) key, convertToWritable(value)); + } else { + logger.log(SentryLevel.ERROR, "Only String keys are supported in Map.", key); + } + } + return writable; + } else if (serialized instanceof List) { + JSArray writable = new JSArray(); + for (Object item : (List) serialized) { + addValueToWritableArray(writable, convertToWritable(item)); + } + return writable; + } + else if (serialized instanceof Byte) { + return Integer.valueOf((Byte) serialized); + } else if (serialized instanceof Short) { + return Integer.valueOf((Short) serialized); + } else if (serialized instanceof Float) { + return Double.valueOf((Float) serialized); + } else if (serialized instanceof Long) { + return Double.valueOf((Long) serialized); + } else if (serialized instanceof BigInteger) { + return ((BigInteger) serialized).doubleValue(); + } else if (serialized instanceof BigDecimal) { + return ((BigDecimal) serialized).doubleValue(); + } else if (serialized instanceof Integer + || serialized instanceof Double + || serialized instanceof Boolean + || serialized == null + || serialized instanceof String) { + return serialized; + } else { + logger.log(SentryLevel.ERROR, "Supplied serialized value could not be converted." + serialized); + return null; + } + } + + private static void addValueToWritableArray(JSArray writableArray, Object value) { + if (value == null) { + writableArray.put(null); + } else if (value instanceof Boolean) { + writableArray.put((Boolean) value); + } else if (value instanceof Double) { + writableArray.put((Double) value); + } else if (value instanceof Float) { + final double doubleValue = ((Float) value).doubleValue(); + writableArray.put(Double.valueOf(doubleValue)); + } else if (value instanceof Integer) { + writableArray.put((Integer) value); + } else if (value instanceof Short) { + writableArray.put(((Short) value).intValue()); + } else if (value instanceof Byte) { + writableArray.put(((Byte) value).intValue()); + } else if (value instanceof Long) { + final double doubleValue = ((Long) value).doubleValue(); + writableArray.put(Double.valueOf(doubleValue)); + } else if (value instanceof BigInteger) { + final double doubleValue = ((BigDecimal) value).doubleValue(); + writableArray.put(Double.valueOf(doubleValue)); + } else if (value instanceof BigDecimal) { + final double doubleValue = ((BigDecimal) value).doubleValue(); + writableArray.put(Double.valueOf(doubleValue)); + } else if (value instanceof String) { + writableArray.put((String) value); + } + else if (value instanceof JSObject) { + writableArray.put((JSObject) value); + } else if (value instanceof JSArray) { + writableArray.put((JSArray) value); + } else { + logger.log(SentryLevel.ERROR, + "Could not convert object: " + value); + } + } + + private static void addValueToWritableMap(JSObject writableMap, String key, Object value) { + if (value == null) { + writableMap.put(key, null); + } else if (value instanceof Boolean) { + writableMap.put(key, (Boolean) value); + } else if (value instanceof Double) { + writableMap.put(key, (Double) value); + } else if (value instanceof Float) { + writableMap.put(key, ((Float) value).doubleValue()); + } else if (value instanceof Integer) { + writableMap.put(key, (Integer) value); + } else if (value instanceof Short) { + writableMap.put(key, ((Short) value).intValue()); + } else if (value instanceof Byte) { + writableMap.put(key, ((Byte) value).intValue()); + } else if (value instanceof Long) { + writableMap.put(key, ((Long) value).doubleValue()); + } else if (value instanceof BigInteger) { + writableMap.put(key, ((BigInteger) value).doubleValue()); + } else if (value instanceof BigDecimal) { + writableMap.put(key, ((BigDecimal) value).doubleValue()); + } else if (value instanceof String) { + writableMap.put(key, (String) value); + } else if (value instanceof JSArray) { + writableMap.put(key, (JSArray) value); + } else if (value instanceof JSObject) { + writableMap.put(key, (JSObject) value); + } else { + logger.log(SentryLevel.ERROR, + "Could not convert object" + value); + } + } +} diff --git a/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java b/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java index f452ec19..7df770d1 100644 --- a/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java +++ b/android/src/main/java/io/sentry/capacitor/SentryCapacitor.java @@ -12,23 +12,28 @@ import io.sentry.Breadcrumb; import io.sentry.HubAdapter; import io.sentry.Integration; +import io.sentry.IScope; import io.sentry.Sentry; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.BuildConfig; import io.sentry.android.core.AnrIntegration; +import io.sentry.android.core.InternalSentrySdk; +import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.NdkIntegration; import io.sentry.android.core.SentryAndroid; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; -import java.io.File; -import java.io.FileOutputStream; + import java.io.UnsupportedEncodingException; +import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.logging.Level; @@ -218,6 +223,36 @@ public void fetchNativeRelease(PluginCall call) { call.resolve(release); } + @PluginMethod + public void fetchNativeSdkInfo(PluginCall call) { + final SdkVersion sdkVersion = HubAdapter.getInstance().getOptions().getSdkVersion(); + if (sdkVersion == null) { + call.resolve(null); + } else { + final JSObject sdkInfo = new JSObject(); + sdkInfo.put("name", sdkVersion.getName()); + sdkInfo.put("version", sdkVersion.getVersion()); + call.resolve(sdkInfo); + } + } + + @PluginMethod + public void fetchNativeDeviceContexts(PluginCall call) { + final SentryOptions options = HubAdapter.getInstance().getOptions(); + if (!(options instanceof SentryAndroidOptions)) { + call.resolve(null); + return; + } + + final IScope currentScope = InternalSentrySdk.getCurrentScope(); + final Map serialized = InternalSentrySdk.serializeScope( + context, + (SentryAndroidOptions) options, + currentScope); + final JSObject deviceContext = (JSObject)CapSentryMapConverter.convertToWritable(serialized); + call.resolve(deviceContext); + } + @PluginMethod public void captureEnvelope(PluginCall call) { try { @@ -226,31 +261,14 @@ public void captureEnvelope(PluginCall call) { for (int i = 0; i < bytes.length; i++) { bytes[i] = (byte) rawIntegers.getInt(i); } - - final String outboxPath = HubAdapter.getInstance().getOptions().getOutboxPath(); - - if (outboxPath == null || outboxPath.isEmpty()) { - logger.info("Error when writing envelope, no outbox path is present."); - call.reject("Missing outboxPath"); - return; - } - - final File installation = new File(outboxPath, UUID.randomUUID().toString()); - - try (FileOutputStream out = new FileOutputStream(installation)) { - out.write(bytes); - logger.info("Successfully captured envelope."); - } catch (Exception e) { - logger.info("Error writing envelope."); - call.reject(String.valueOf(e)); - return; - } - } catch (Exception e) { - logger.info("Error reading envelope."); - call.reject(String.valueOf(e)); - return; - } - call.resolve(); + InternalSentrySdk.captureEnvelope(bytes); + call.resolve(); + } + catch (Throwable e) { + final String errorMessage ="Error while capturing envelope"; + logger.log(Level.WARNING, errorMessage); + call.reject(errorMessage); + } } @PluginMethod @@ -272,7 +290,10 @@ public void getStringBytesLength(PluginCall call) { @PluginMethod public void addBreadcrumb(final PluginCall breadcrumb) { Sentry.configureScope(scope -> { - Breadcrumb breadcrumbInstance = new Breadcrumb(); + Date jsTimestamp = + new Date((long)(breadcrumb.getDouble("timestamp")*1000)); + + Breadcrumb breadcrumbInstance = new Breadcrumb(jsTimestamp); if (breadcrumb.getData().has("message")) { breadcrumbInstance.setMessage(breadcrumb.getString("message")); @@ -322,8 +343,8 @@ public void addBreadcrumb(final PluginCall breadcrumb) { } scope.addBreadcrumb(breadcrumbInstance); - }); - breadcrumb.resolve(); + }); + breadcrumb.resolve(); } @PluginMethod diff --git a/example/ionic-angular-v5/src/app/tab1/tab1.page.ts b/example/ionic-angular-v5/src/app/tab1/tab1.page.ts index dfd082f2..1b70a621 100644 --- a/example/ionic-angular-v5/src/app/tab1/tab1.page.ts +++ b/example/ionic-angular-v5/src/app/tab1/tab1.page.ts @@ -20,7 +20,9 @@ export class Tab1Page { } public sentryCapturedException(): void { - Sentry.captureException(new Error(`${Date.now()}: a test error occurred`)); + Sentry.captureException(new Error(`${Date.now()}: a test error occurred`), (context) => { + return context.addBreadcrumb({ message: 'test' }); + }); } public errorWithUserData(): void { diff --git a/src/breadcrumb.ts b/src/breadcrumb.ts new file mode 100644 index 00000000..eaf33881 --- /dev/null +++ b/src/breadcrumb.ts @@ -0,0 +1,42 @@ +import type { Breadcrumb, SeverityLevel } from '@sentry/types'; +import { severityLevelFromString } from '@sentry/utils'; + +export const DEFAULT_BREADCRUMB_LEVEL: SeverityLevel = 'info'; + +type BreadcrumbCandidate = { + [K in keyof Partial]: unknown; +}; + +/** + * Convert plain object to a valid Breadcrumb + */ +export function breadcrumbFromObject(candidate: BreadcrumbCandidate): Breadcrumb { + const breadcrumb: Breadcrumb = {}; + + if (typeof candidate.type === 'string') { + breadcrumb.type = candidate.type; + } + if (typeof candidate.level === 'string') { + breadcrumb.level = severityLevelFromString(candidate.level); + } + if (typeof candidate.event_id === 'string') { + breadcrumb.event_id = candidate.event_id; + } + if (typeof candidate.category === 'string') { + breadcrumb.category = candidate.category; + } + if (typeof candidate.message === 'string') { + breadcrumb.message = candidate.message; + } + if (typeof candidate.data === 'object' && candidate.data !== null) { + breadcrumb.data = candidate.data; + } + if (typeof candidate.timestamp === 'string') { + const timestampSeconds = Date.parse(candidate.timestamp) / 1000; // breadcrumb timestamp is in seconds + if (!isNaN(timestampSeconds)) { + breadcrumb.timestamp = timestampSeconds; + } + } + + return breadcrumb; +} diff --git a/src/definitions.ts b/src/definitions.ts index 0d15de4a..3f5c0095 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -8,7 +8,30 @@ interface serializedObject { } export type NativeDeviceContextsResponse = { - [key: string]: Record; + [key: string]: unknown; + tags?: Record; + extra?: Record; + contexts?: Record>; + user?: { + userId?: string; + email?: string; + username?: string; + ipAddress?: string; + segment?: string; + data?: Record; + }; + dist?: string; + environment?: string; + fingerprint?: string[]; + level?: string; + breadcrumbs?: { + level?: string; + timestamp?: string; + category?: string; + type?: string; + message?: string; + data?: Record; + }[]; }; export interface ISentryCapacitorPlugin { diff --git a/src/integrations/devicecontext.ts b/src/integrations/devicecontext.ts index e70bd52d..3084e453 100644 --- a/src/integrations/devicecontext.ts +++ b/src/integrations/devicecontext.ts @@ -1,7 +1,8 @@ -import { addEventProcessor, getCurrentHub } from '@sentry/core'; -import type { Contexts, Event, Integration } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import type { Breadcrumb, Event, EventProcessor, Hub, Integration } from '@sentry/types'; +import { logger, severityLevelFromString } from '@sentry/utils'; +import type { NativeDeviceContextsResponse } from 'src/definitions'; +import { breadcrumbFromObject } from '../breadcrumb'; import { NATIVE } from '../wrapper'; /** Load device context from native. */ @@ -19,29 +20,127 @@ export class DeviceContext implements Integration { /** * @inheritDoc */ - public setupOnce(): void { - addEventProcessor(async (event: Event) => { - const self = getCurrentHub().getIntegration(DeviceContext); + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + // eslint-disable-next-line complexity + addGlobalEventProcessor(async (event: Event) => { + const hub = getCurrentHub(); + const self = hub.getIntegration(DeviceContext); if (!self) { return event; } + let nativeContexts: NativeDeviceContextsResponse | null = null; try { - const contexts = await NATIVE.fetchNativeDeviceContexts(); - const context = (contexts['context'] as Contexts); - - event.contexts = { ...context, ...event.contexts }; - if ('user' in contexts) { - const user = contexts['user']; - if (!event.user) { - event.user = { ...user }; - } - } + nativeContexts = await NATIVE.fetchNativeDeviceContexts(); } catch (e) { logger.log(`Failed to get device context from native: ${e}`); } + if (!nativeContexts) { + return event; + } + + if (nativeContexts.contexts) { + event.contexts = { ...nativeContexts.contexts, ...event.contexts }; + if (nativeContexts.contexts.app) { + event.contexts.app = { ...nativeContexts.contexts.app, ...event.contexts.app }; + } + } + if (nativeContexts.user) { + const user = nativeContexts.user; + if (!event.user) { + event.user = { ...user }; + } + } + const nativeTags = nativeContexts.tags; + if (nativeTags) { + event.tags = { ...nativeTags, ...event.tags }; + } + + const nativeExtra = nativeContexts.extra; + if (nativeExtra) { + event.extra = { ...nativeExtra, ...event.extra }; + } + + const nativeFingerprint = nativeContexts.fingerprint; + if (nativeFingerprint) { + event.fingerprint = (event.fingerprint ?? []).concat( + nativeFingerprint.filter(item => (event.fingerprint ?? []).indexOf(item) < 0), + ); + } + + if (!event.level && nativeContexts.level) { + event.level = severityLevelFromString(nativeContexts.level); + } + + const nativeEnvironment = nativeContexts.environment; + if (!event.environment && nativeEnvironment) { + event.environment = nativeEnvironment; + } + + const nativeBreadcrumbs = Array.isArray(nativeContexts.breadcrumbs) + ? nativeContexts.breadcrumbs.map(breadcrumbFromObject) + : undefined; + if (nativeBreadcrumbs) { + if (event.breadcrumbs && event.breadcrumbs.length !== nativeBreadcrumbs.length) { + const maxBreadcrumbs = hub.getClient()?.getOptions().maxBreadcrumbs ?? 100; // Default is 100. + event.breadcrumbs = this._mergeUniqueBreadcrumbs(event.breadcrumbs, nativeBreadcrumbs, maxBreadcrumbs); + } + else { + event.breadcrumbs = nativeBreadcrumbs; + } + } return event; }); } + + /** + * Merges two groups of ordered breadcrumbs and removes any duplication that may + * happen between them. + * @param jsList The first group of breadcrumbs from the JavaScript layer. + * @param nativeList The second group of breadcrumbs from the native layer. + * @returns An array of unique breadcrumbs merged from both lists. + */ + private _mergeUniqueBreadcrumbs(jsList: Breadcrumb[], nativeList: Breadcrumb[], maxBreadcrumbs: number): Breadcrumb[] { + // Ensure both lists are ordered by timestamp. + const orderedNativeList = [...nativeList].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); + const orderedJsList = [...jsList].sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)); + + const combinedList: Breadcrumb[] = []; + let jsIndex = 0; + let natIndex = 0; + + while (jsIndex < orderedJsList.length && natIndex < orderedNativeList.length && combinedList.length < maxBreadcrumbs) + { + const jsBreadcrumb = orderedJsList[jsIndex]; + const natBreadcrumb = orderedNativeList[natIndex]; + + if (jsBreadcrumb.timestamp === natBreadcrumb.timestamp && + jsBreadcrumb.message === natBreadcrumb.message) { + combinedList.push(jsBreadcrumb); + jsIndex++; + natIndex++; + } + else if (jsBreadcrumb.timestamp && natBreadcrumb.timestamp && + jsBreadcrumb.timestamp < natBreadcrumb.timestamp) + { + combinedList.push(jsBreadcrumb); + jsIndex++; + } + else { + combinedList.push(natBreadcrumb); + natIndex++; + } + } + + // Add remaining breadcrumbs from the JavaScript and Native list if space allows. + while (jsIndex < orderedJsList.length && combinedList.length < maxBreadcrumbs) { + combinedList.push(orderedJsList[jsIndex++]); + } + + while (natIndex < orderedNativeList.length && combinedList.length < maxBreadcrumbs) { + combinedList.push(orderedNativeList[natIndex++]); + } + return combinedList; + } } diff --git a/src/integrations/sdkinfo.ts b/src/integrations/sdkinfo.ts index 88df3515..b71bd602 100644 --- a/src/integrations/sdkinfo.ts +++ b/src/integrations/sdkinfo.ts @@ -23,9 +23,8 @@ export class SdkInfo implements Integration { */ public setupOnce(addGlobalEventProcessor: (e: EventProcessor) => void): void { addGlobalEventProcessor(async event => { - // The native SDK info package here is only used on iOS as `beforeSend` is not called on `captureEnvelope`. // this._nativeSdkInfo should be defined a following time so this call won't always be awaited. - if (NATIVE.platform === 'ios' && this._nativeSdkPackage === null) { + if (this._nativeSdkPackage === null) { try { this._nativeSdkPackage = await NATIVE.fetchNativeSdkInfo(); } catch (_) { diff --git a/src/wrapper.ts b/src/wrapper.ts index 3225eafc..e2a34f0c 100644 --- a/src/wrapper.ts +++ b/src/wrapper.ts @@ -115,11 +115,6 @@ export const NATIVE = { throw this._NativeClientError; } - if (this.platform !== 'ios') { - // Only ios uses deviceContexts, return an empty object. - return {}; - } - return SentryCapacitor.fetchNativeDeviceContexts(); }, @@ -241,24 +236,27 @@ export const NATIVE = { * Adds breadcrumb to the native scope. * @param breadcrumb Breadcrumb */ - addBreadcrumb(breadcrumb: Breadcrumb): void { - if (!this.enableNative) { + addBreadcrumb(breadcrumb: Breadcrumb): void { + if (!this.enableNative) { return; } if (!this.isNativeClientAvailable()) { throw this._NativeClientError; } + // There is a tiny difference on the timestamp created by the Native Layer and the JavaScript layer. + // We update the timestamp on the JavaScript layer with the given timestamp from the Native layer so both + // layers have their timestamp matching. SentryCapacitor.addBreadcrumb({ - ...breadcrumb, - // Process and convert deprecated levels - level: breadcrumb.level - ? this._processLevel(breadcrumb.level) - : undefined, - data: breadcrumb.data - ? convertToNormalizedObject(breadcrumb.data) - : undefined, - }); + ...breadcrumb, + // Process and convert deprecated levels + level: breadcrumb.level + ? this._processLevel(breadcrumb.level) + : undefined, + data: breadcrumb.data + ? convertToNormalizedObject(breadcrumb.data) + : undefined, + }); }, /** @@ -271,7 +269,6 @@ export const NATIVE = { if (!this.isNativeClientAvailable()) { throw this._NativeClientError; } - SentryCapacitor.clearBreadcrumbs(); }, @@ -306,32 +303,18 @@ export const NATIVE = { * @returns The event from envelopeItem or undefined. */ _processItem(item: EnvelopeItem): EnvelopeItem { - if (NATIVE.platform === 'android') { const [itemHeader, itemPayload] = item; if (itemHeader.type == 'event' || itemHeader.type == 'transaction') { const event = this._processLevels(itemPayload as Event); - if ('message' in event) { + if ('message' in event && this.platform === 'android') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Android still uses the old message object, without this the serialization of events will break. event.message = { message: event.message }; } - /* - We do this to avoid duplicate breadcrumbs on Android as sentry-android applies the breadcrumbs - from the native scope onto every envelope sent through it. This scope will contain the breadcrumbs - sent through the scope sync feature. This causes duplicate breadcrumbs. - We then remove the breadcrumbs in all cases but if it is handled == false, - this is a signal that the app would crash and android would lose the breadcrumbs by the time the app is restarted to read - the envelope. - Since unhandled errors from Javascript are not going to crash the App, we can't rely on the - handled flag for filtering breadcrumbs. - */ - if (event.breadcrumbs) { - event.breadcrumbs = []; - } + return [itemHeader, event]; } - } return item; }, diff --git a/test/integrations/devicecontext.test.ts b/test/integrations/devicecontext.test.ts new file mode 100644 index 00000000..1a96975a --- /dev/null +++ b/test/integrations/devicecontext.test.ts @@ -0,0 +1,393 @@ +import type { Hub } from '@sentry/core'; +import type { Breadcrumb, Event, SeverityLevel } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import type { NativeDeviceContextsResponse } from '../../src/definitions'; +import { DeviceContext } from '../../src/integrations'; +import { NATIVE } from '../../src/wrapper'; + +jest.mock('../../src/wrapper'); + +describe('Device Context Integration', () => { + let integration: DeviceContext; + + const mockGetCurrentHub = () => + ({ + getIntegration: () => integration, + } as unknown as Hub); + + beforeEach(() => { + integration = new DeviceContext(); + }); + + + it('return original event if integration fails', async () => { + logger.log = jest.fn(); + + const originalEvent = { environment: 'original' } as Event; + + ( + NATIVE.fetchNativeDeviceContexts as jest.MockedFunction + ).mockImplementation(() => Promise.reject(new Error('it failed :('))) + + const returnedEvent = await executeIntegrationFor(originalEvent); + expect(returnedEvent).toStrictEqual(originalEvent); + expect(logger.log).toBeCalledTimes(1); + expect(logger.log).toBeCalledWith('Failed to get device context from native: Error: it failed :('); + }); + + it('add native user', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { user: { id: 'native-user' } }, + }) + ).expectEvent.toStrictEqualToNativeContexts(); + }); + + it('do not overwrite event user', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { user: { id: 'native-user' } }, + mockEvent: { user: { id: 'event-user' } }, + }) + ).expectEvent.toStrictEqualMockEvent(); + }); + + it('do not overwrite event app context', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { app: { view_names: ['native view'] } }, + mockEvent: { contexts: { app: { view_names: ['Home'] } } }, + }) + ).expectEvent.toStrictEqualMockEvent(); + }); + + it('merge event context app', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { contexts: { app: { native: 'value' } } }, + mockEvent: { contexts: { app: { event_app: 'value' } } }, + }); + expect(processedEvent).toStrictEqual({ + contexts: { + app: { + event_app: 'value', + native: 'value', + }, + }, + }); + }); + + it('merge event context app even when event app doesnt exist', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { contexts: { app: { native: 'value' } } }, + mockEvent: { contexts: { keyContext: { key: 'value' } } }, + }); + expect(processedEvent).toStrictEqual({ + contexts: { + keyContext: { + key: 'value', + }, + app: { + native: 'value', + }, + }, + }); + }); + + it('merge event and native contexts', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { contexts: { duplicate: { context: 'native-value' }, native: { context: 'value' } } }, + mockEvent: { contexts: { duplicate: { context: 'event-value' }, event: { context: 'value' } } }, + }); + expect(processedEvent).toStrictEqual({ + contexts: { + duplicate: { context: 'event-value' }, + native: { context: 'value' }, + event: { context: 'value' }, + }, + }); + }); + + it('merge native tags', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { tags: { duplicate: 'native-tag', native: 'tag' } }, + mockEvent: { tags: { duplicate: 'event-tag', event: 'tag' } }, + }); + expect(processedEvent).toStrictEqual({ + tags: { + duplicate: 'event-tag', + native: 'tag', + event: 'tag', + } + }); + }); + + it('merge native extra', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { extra: { duplicate: 'native-extra', native: 'extra' } }, + mockEvent: { extra: { duplicate: 'event-extra', event: 'extra' } }, + }); + expect(processedEvent).toStrictEqual({ + extra: { + duplicate: 'event-extra', + native: 'extra', + event: 'extra', + }, + }); + }); + + it('merge fingerprints', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { fingerprint: ['duplicate-fingerprint', 'native-fingerprint'] }, + mockEvent: { fingerprint: ['duplicate-fingerprint', 'event-fingerprint'] }, + }); + expect(processedEvent).toStrictEqual({ + fingerprint: ['duplicate-fingerprint', 'event-fingerprint', 'native-fingerprint'] + }); + }); + + it('add native level', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { level: 'fatal' }, + }) + ).expectEvent.toStrictEqualToNativeContexts(); + }); + + it('do not overwrite event level', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { level: 'native-level' }, + mockEvent: { level: 'info' }, + }) + ).expectEvent.toStrictEqualMockEvent(); + }); + + it('add native environment', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { environment: 'native-environment' }, + }) + ).expectEvent.toStrictEqualToNativeContexts(); + }); + + it('do not overwrite event environment', async () => { + ( + await executeIntegrationWith({ + nativeContexts: { environment: 'native-environment' }, + mockEvent: { environment: 'event-environment' }, + }) + ).expectEvent.toStrictEqualMockEvent(); + }); + + it('use only native breadcrumbs', async () => { + const { processedEvent } = await executeIntegrationWith({ + nativeContexts: { breadcrumbs: [{ message: 'duplicate-breadcrumb' }, { message: 'native-breadcrumb' }] }, + mockEvent: { breadcrumbs: [{ message: 'duplicate-breadcrumb' }, { message: 'event-breadcrumb' }] }, + }); + expect(processedEvent).toStrictEqual({ + breadcrumbs: [{ message: 'duplicate-breadcrumb' }, { message: 'native-breadcrumb' }] + }); + }); + + async function executeIntegrationWith({ + nativeContexts, + mockEvent, + }: { + nativeContexts: Record; + mockEvent?: Event; + }): Promise<{ + processedEvent: Event | null; + expectEvent: { + toStrictEqualToNativeContexts: () => void; + toStrictEqualMockEvent: () => void; + }; + }> { + ( + NATIVE.fetchNativeDeviceContexts as jest.MockedFunction + ).mockImplementation(() => Promise.resolve(nativeContexts as NativeDeviceContextsResponse)); + const originalNativeContexts = { ...nativeContexts }; + const originalMockEvent = { ...mockEvent }; + const processedEvent = await executeIntegrationFor(mockEvent ?? {}); + return { + processedEvent, + expectEvent: { + toStrictEqualToNativeContexts: () => expect(processedEvent).toStrictEqual(originalNativeContexts), + toStrictEqualMockEvent: () => expect(processedEvent).toStrictEqual(originalMockEvent), + }, + }; + } + + function executeIntegrationFor(mockedEvent: Event): Promise { + return new Promise((resolve, reject) => { + integration.setupOnce(async eventProcessor => { + try { + const processedEvent = await eventProcessor(mockedEvent, {}); + resolve(processedEvent); + } catch (e) { + reject(e); + } + }, mockGetCurrentHub); + }); + } +}); + +describe('Device Context Breadcrumb filter', () => { + const integration = new DeviceContext(); + const mergeUniqueBreadcrumbs = integration['_mergeUniqueBreadcrumbs']; + const defaultMaxBreadcrumb = 100; + + it('merge breadcrumbs if same timestamp and message', async () => { + const jsList = [{ timestamp: 1, message: 'duplicated breadcrumb' }] as Breadcrumb[]; + const nativeList = [{ timestamp: 1, message: 'duplicated breadcrumb' }] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb)).toStrictEqual( + [{ timestamp: 1, message: 'duplicated breadcrumb' }] as Breadcrumb[]); + }); + + it('merge breadcrumbs on different index', async () => { + const jsList = [{ timestamp: 2, message: 'duplicated breadcrumb' }] as Breadcrumb[]; + const nativeList = [ + { timestamp: 1, message: 'new natieve' }, + { timestamp: 2, message: 'duplicated breadcrumb' }] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb)).toStrictEqual([ + { timestamp: 1, message: 'new natieve' }, + { timestamp: 2, message: 'duplicated breadcrumb' }] as Breadcrumb[]); + }); + + it('merge breadcrumbs that are out of order', async () => { + const jsList = [ + { timestamp: 2, message: 'duplicated breadcrumb 2' }, + { timestamp: 1, message: 'duplicated breadcrumb' }, + { timestamp: 3, message: 'new js' }, + { timestamp: 4, message: 'new js' }, + ] as Breadcrumb[]; + const nativeList = [ + { timestamp: 2, message: 'new native' }, + { timestamp: 1, message: 'duplicated breadcrumb' }, + { timestamp: 2, message: 'duplicated breadcrumb 2' }, + { timestamp: 3, message: 'new native' }, + { timestamp: 4, message: 'new native' },] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb)).toStrictEqual([ + { timestamp: 1, message: 'duplicated breadcrumb' }, + { timestamp: 2, message: 'new native' }, + { timestamp: 2, message: 'duplicated breadcrumb 2' }, + { timestamp: 3, message: 'new native' }, + { timestamp: 3, message: 'new js' }, + { timestamp: 4, message: 'new native' }, + { timestamp: 4, message: 'new js' }] as Breadcrumb[]); + }); + + it('joins different breadcrumbs', async () => { + const jsList = [ + { timestamp: 1, message: 'new js' }, + { timestamp: 2, message: 'new js' }] as Breadcrumb[]; + const nativeList = [ + { timestamp: 1, message: 'new native' }, + { timestamp: 2, message: 'new native' }] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb)).toStrictEqual([ + { timestamp: 1, message: 'new native' }, + { timestamp: 1, message: 'new js' }, + { timestamp: 2, message: 'new native' }, + { timestamp: 2, message: 'new js' }] as Breadcrumb[]); + }); + + it('all javascript breadcrumbs merged when list is larger than native one', async () => { + const jsList = [ + { timestamp: 1, message: 'new js' }, + { timestamp: 2, message: 'new js' }, + { timestamp: 3, message: 'new js' }, + { timestamp: 4, message: 'new js' }] as Breadcrumb[]; + const nativeList = [ + { timestamp: 1, message: 'new native' }, + { timestamp: 2, message: 'new native' }] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb)).toStrictEqual([ + { timestamp: 1, message: 'new native' }, + { timestamp: 1, message: 'new js' }, + { timestamp: 2, message: 'new native' }, + { timestamp: 2, message: 'new js' }, + { timestamp: 3, message: 'new js' }, + { timestamp: 4, message: 'new js' }] as Breadcrumb[]); + }); + + it('all native breadcrumbs merged when list is larger than javascripty one', async () => { + const jsList = [ + { timestamp: 1, message: 'new js' }, + { timestamp: 2, message: 'new js' }] as Breadcrumb[]; + const nativeList = [ + { timestamp: 1, message: 'new native' }, + { timestamp: 2, message: 'new native' }, + { timestamp: 3, message: 'new native' }, + { timestamp: 4, message: 'new native' }] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb)).toStrictEqual([ + { timestamp: 1, message: 'new native' }, + { timestamp: 1, message: 'new js' }, + { timestamp: 2, message: 'new native' }, + { timestamp: 2, message: 'new js' }, + { timestamp: 3, message: 'new native' }, + { timestamp: 4, message: 'new native' }] as Breadcrumb[]); + }); + + it('Handles empty input arrays', () => { + // Mock empty input arrays + const jsList: Breadcrumb[] = []; + const nativeList: Breadcrumb[] = []; + + // Call the private method + const result = mergeUniqueBreadcrumbs(jsList, nativeList, defaultMaxBreadcrumb); + + // Assert that the result is an empty array + expect(result).toEqual([]); + }); + + describe('respect maxBreadcrumb limits', async () => { + const newLimit_3 = 3; + + it('limit JavaScript list', async () => { + const jsList = [ + { timestamp: 1, message: 'js breadcrumb' }, + { timestamp: 2, message: 'js breadcrumb' }, + { timestamp: 3, message: 'js breadcrumb' }, + { timestamp: 4, message: 'js breadcrumb' }, + { timestamp: 5, message: 'js breadcrumb' },] as Breadcrumb[]; + const nativeList = [] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, newLimit_3)).toStrictEqual([ + { timestamp: 1, message: 'js breadcrumb' }, + { timestamp: 2, message: 'js breadcrumb' }, + { timestamp: 3, message: 'js breadcrumb' }] as Breadcrumb[]); + }); + + it('limit Native list', async () => { + const nativeList = [ + { timestamp: 1, message: 'native breadcrumb' }, + { timestamp: 2, message: 'native breadcrumb' }, + { timestamp: 3, message: 'native breadcrumb' }, + { timestamp: 4, message: 'native breadcrumb' }, + { timestamp: 5, message: 'native breadcrumb' },] as Breadcrumb[]; + const jsList = [] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, newLimit_3)).toStrictEqual([ + { timestamp: 1, message: 'native breadcrumb' }, + { timestamp: 2, message: 'native breadcrumb' }, + { timestamp: 3, message: 'native breadcrumb' }] as Breadcrumb[]); + }); + + it('limit Merge', async () => { + const nativeList = [ + { timestamp: 1, message: 'duplicated breadcrumb' }, + { timestamp: 2, message: 'duplicated breadcrumb' }, + { timestamp: 3, message: 'duplicated breadcrumb' }, + { timestamp: 4, message: 'duplicated breadcrumb' }, + { timestamp: 5, message: 'duplicated breadcrumb' },] as Breadcrumb[]; + const jsList = [ + { timestamp: 1, message: 'duplicated breadcrumb' }, + { timestamp: 2, message: 'duplicated breadcrumb' }, + { timestamp: 3, message: 'duplicated breadcrumb' }, + { timestamp: 4, message: 'duplicated breadcrumb' }, + { timestamp: 5, message: 'duplicated breadcrumb' },] as Breadcrumb[]; + expect(mergeUniqueBreadcrumbs(jsList, nativeList, newLimit_3)).toStrictEqual([ + { timestamp: 1, message: 'duplicated breadcrumb' }, + { timestamp: 2, message: 'duplicated breadcrumb' }, + { timestamp: 3, message: 'duplicated breadcrumb' }] as Breadcrumb[]); + }); + + }); +}); diff --git a/test/integrations/sdkinfo.test.ts b/test/integrations/sdkinfo.test.ts index 7224f4f5..21563d65 100644 --- a/test/integrations/sdkinfo.test.ts +++ b/test/integrations/sdkinfo.test.ts @@ -6,11 +6,16 @@ import { NATIVE } from '../../src/wrapper'; let mockedFetchNativeSdkInfo: jest.Mock, []>; -const mockPackage = { +const iOSMockPackage = { name: 'sentry-cocoa', version: '0.0.1', }; +const droidMockPackage = { + name: 'sentry-java', + version: '0.0.1', +}; + jest.mock('../../src/wrapper', () => { const actual = jest.requireActual('../../src/wrapper'); @@ -23,30 +28,31 @@ jest.mock('../../src/wrapper', () => { }; }); -afterEach(() => { - NATIVE.platform = 'ios'; -}); - describe('Sdk Info', () => { + afterEach(() => { + NATIVE.platform = 'ios'; + }); + + it('Adds native package and javascript platform to event on iOS', async () => { - mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(mockPackage); + mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(iOSMockPackage); const mockEvent: Event = {}; const processedEvent = await executeIntegrationFor(mockEvent); - expect(processedEvent?.sdk?.packages).toEqual(expect.arrayContaining([mockPackage])); + expect(processedEvent?.sdk?.packages).toEqual(expect.arrayContaining([iOSMockPackage])); expect(processedEvent?.platform === 'javascript'); expect(mockedFetchNativeSdkInfo).toBeCalledTimes(1); }); - it('Adds javascript platform but not native package on Android', async () => { + it('Adds javascript platform and javascript platform to event on Android', async () => { NATIVE.platform = 'android'; - mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(mockPackage); + mockedFetchNativeSdkInfo = jest.fn().mockResolvedValue(droidMockPackage); const mockEvent: Event = {}; const processedEvent = await executeIntegrationFor(mockEvent); - expect(processedEvent?.sdk?.packages).toEqual(expect.not.arrayContaining([mockPackage])); + expect(processedEvent?.sdk?.packages).toEqual(expect.arrayContaining([droidMockPackage])); expect(processedEvent?.platform === 'javascript'); - expect(mockedFetchNativeSdkInfo).not.toBeCalled(); + expect(mockedFetchNativeSdkInfo).toBeCalledTimes(1); }); it('Does not overwrite existing sdk name and version', async () => { diff --git a/test/nativeOptions.test.ts b/test/nativeOptions.test.ts index 30e5d005..d6f9090d 100644 --- a/test/nativeOptions.test.ts +++ b/test/nativeOptions.test.ts @@ -6,14 +6,13 @@ import { FilterNativeOptions } from '../src/nativeOptions'; // Mock the Capacitor module jest.mock('@capacitor/core', () => ({ + ...jest.requireActual('@capacitor/core'), Capacitor: { getPlatform: jest.fn() } })); - describe('nativeOptions', () => { - test('Use value of enableOutOfMemoryTracking on enableWatchdogTerminationTracking when set true', async () => { const nativeOptions = FilterNativeOptions( { @@ -120,7 +119,7 @@ describe('nativeOptions', () => { appHangTimeoutInterval: 123 }; const nativeOptions = FilterNativeOptions(expectedOptions); - expect(JSON.stringify(nativeOptions)).toEqual(JSON.stringify(expectedOptions)); + expect(JSON.stringify(nativeOptions)).toEqual(JSON.stringify(expectedOptions)); }); test('Should not include iOS parameters when running on android', async () => { @@ -135,7 +134,7 @@ describe('nativeOptions', () => { enableAppHangTracking: true } }); - expect(nativeOptions).toEqual(expectedOptions); + expect(nativeOptions).toEqual(expectedOptions); }); }); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 1af45c51..89ee8940 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -1,4 +1,4 @@ -import type { BrowserOptions} from '@sentry/browser'; +import type { BrowserOptions } from '@sentry/browser'; import type { Integration } from '@sentry/types'; import type { CapacitorOptions } from '../src'; @@ -14,10 +14,6 @@ jest.mock('../src/wrapper', () => { }; }); -beforeEach(() => { - jest.clearAllMocks(); -}); - describe('SDK Init', () => { // [name, platform, options, autoSessionTracking, enableNative, enableAutoSessionTracking] const table: Array<[ diff --git a/test/transports/native.test.ts b/test/transports/native.test.ts index 497a31ed..5a7ce7af 100644 --- a/test/transports/native.test.ts +++ b/test/transports/native.test.ts @@ -1,16 +1,6 @@ -import { BrowserClient , - defaultIntegrations, - } from '@sentry/browser'; -import type { BrowserClientOptions } from '@sentry/browser/types/client'; -import type { BrowserTransportOptions } from '@sentry/browser/types/transports/types'; -import type { FetchImpl } from '@sentry/browser/types/transports/utils'; -import type { Event,Transport } from '@sentry/types'; +import type { Envelope } from '@sentry/types'; import { NativeTransport } from '../../src/transports/native'; -import { NATIVE } from '../../src/wrapper'; - -const EXAMPLE_DSN = - 'https://6890c2f6677340daa4804f8194804ea2@o19635.ingest.sentry.io/148053'; jest.mock('../../src/wrapper', () => ({ NATIVE: { @@ -19,35 +9,8 @@ jest.mock('../../src/wrapper', () => ({ })); describe('NativeTransport', () => { - test('does not include sdkProcessingMetadata on event sent', async () => { - const event = { - event_id: 'event0', - message: 'test©', - sdk: { - name: 'test-sdk-name', - version: '1.2.3', - }, - sdkProcessingMetadata: ['uneeeded data.', 'xz'] - }; - const captureEnvelopeSpy = jest.spyOn(NATIVE, 'sendEnvelope'); - - const nativeTransport = new NativeTransport(); - const transport = (_options: BrowserTransportOptions, _nativeFetch?: FetchImpl): Transport => nativeTransport; - const x = new BrowserClient( - { - transport: transport, - enabled: true, - integrations: defaultIntegrations, - dsn: EXAMPLE_DSN - } as BrowserClientOptions); - x.captureEvent(event); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(NATIVE.sendEnvelope).toBeCalledTimes(1); - - const receivedEvent = captureEnvelopeSpy.mock.calls[0][0][1][0][1] as Event; - - expect(receivedEvent.event_id).toContain(event.event_id); - expect(receivedEvent).not.toContain('sdkProcessingMetadata'); + test('call native sendEvent', async () => { + const transport = new NativeTransport(); + await expect(transport.send({} as Envelope)).resolves.toEqual({ status: 200 }); }); }); diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts index 26c1fca0..640ce9b3 100644 --- a/test/wrapper.test.ts +++ b/test/wrapper.test.ts @@ -2,6 +2,7 @@ import type { Envelope, EventEnvelope, EventItem, SeverityLevel, TransportMakeRequestResponse } from '@sentry/types'; import { createEnvelope, logger } from '@sentry/utils'; +import { SentryCapacitor } from '../src/plugin'; import { utf8ToBytes } from '../src/vendor'; import { NATIVE } from '../src/wrapper'; @@ -11,23 +12,18 @@ function NumberArrayToString(numberArray: number[]): string { return new TextDecoder().decode(new Uint8Array(numberArray).buffer); } -jest.mock( - '@capacitor/core', - () => { - const original = jest.requireActual('@capacitor/core'); - - return { - WebPlugin: original.WebPlugin, - registerPlugin: jest.fn(), - Capacitor: { - isPluginAvailable: jest.fn(() => true), - getPlatform: jest.fn(() => 'android'), - }, - }; - }, - /* virtual allows us to mock modules that aren't in package.json */ - { virtual: true }, -); +jest.mock('@capacitor/core', () => { + const original = jest.requireActual('@capacitor/core'); + + return { + WebPlugin: original.WebPlugin, + registerPlugin: jest.fn(), + Capacitor: { + isPluginAvailable: jest.fn(() => true), + getPlatform: jest.fn(() => 'android'), + }, + }; +}); jest.mock('../src/plugin', () => { return { @@ -65,19 +61,18 @@ jest.mock('../src/plugin', () => { }; }); -import { SentryCapacitor } from '../src/plugin'; +describe('Tests Native Wrapper', () => { -beforeEach(() => { - getStringBytesLengthValue = 1; - NATIVE.enableNative = true; - NATIVE.platform = 'android'; -}); + beforeEach(() => { + getStringBytesLengthValue = 1; + NATIVE.enableNative = true; + NATIVE.platform = 'android'; + }); -afterEach(() => { - jest.clearAllMocks(); -}); + afterEach(() => { + jest.clearAllMocks(); + }); -describe('Tests Native Wrapper', () => { describe('initNativeSdk', () => { test('calls plugin', async () => { SentryCapacitor.initNativeSdk = jest.fn(); @@ -102,6 +97,7 @@ describe('Tests Native Wrapper', () => { expect(nativeOption.vue).toBeUndefined(); expect(initNativeSdk).toBeCalled(); + initNativeSdk.mockRestore() }); @@ -132,6 +128,7 @@ describe('Tests Native Wrapper', () => { expect(nativeOption.tracesSampler).toBeUndefined(); expect(initNativeSdk).toBeCalled(); + initNativeSdk.mockRestore() }); test('warns if there is no dsn', async () => { @@ -227,115 +224,7 @@ describe('Tests Native Wrapper', () => { expect(SentryCapacitor.captureEnvelope).not.toBeCalled(); }); - test('Clears breadcrumbs on Android if there is no exception', async () => { - NATIVE.platform = 'android'; - - const event = { - event_id: 'event0', - message: 'test', - breadcrumbs: [ - { - message: 'crumb!', - }, - ], - sdk: { - name: 'test-sdk-name', - version: '1.2.3', - }, - }; - - const expectedHeader = JSON.stringify({ - event_id: event.event_id, - sent_at: '123' - }); - const expectedItem = JSON.stringify({ - type: 'event', - content_type: 'application/json', - length: 116, - }); - const expectedPayload = JSON.stringify({ - ...event, - breadcrumbs: [], - message: { - message: event.message, - }, - }); - - const env = createEnvelope({ event_id: event.event_id, sent_at: '123' }, [ - [{ type: 'event' }, event] as EventItem, - ]); - - const captureEnvelopeSpy = jest.spyOn(SentryCapacitor, 'captureEnvelope'); - - await NATIVE.sendEnvelope(env); - - expect(SentryCapacitor.captureEnvelope).toBeCalledTimes(1); - expect(NumberArrayToString(captureEnvelopeSpy.mock.calls[0][0].envelope)).toMatch( - `${expectedHeader}\n${expectedItem}\n${expectedPayload}`); - }); - - test('Clears breadcrumbs on Android if there is a handled exception', async () => { - NATIVE.platform = 'android'; - - const event = { - event_id: 'event0', - message: 'test', - breadcrumbs: [ - { - message: 'crumb!', - }, - ], - exception: { - values: [{ - mechanism: { - handled: true - } - }] - }, - sdk: { - name: 'test-sdk-name', - version: '1.2.3', - }, - }; - - const env = createEnvelope({ event_id: event.event_id, sent_at: '123' }, [ - [{ type: 'event' }, event] as EventItem, - ]); - - const expectedHeader = JSON.stringify({ - event_id: event.event_id, - sent_at: '123' - }); - const expectedItem = JSON.stringify({ - type: 'event', - content_type: 'application/json', - length: 172, - }); - const expectedPayload = JSON.stringify({ - ...event, - breadcrumbs: [], - message: { - message: event.message, - }, - exception: { - values: [{ - mechanism: { - handled: true - } - }] - } - }); - - const captureEnvelopeSpy = jest.spyOn(SentryCapacitor, 'captureEnvelope'); - - await NATIVE.sendEnvelope(env); - - expect(SentryCapacitor.captureEnvelope).toBeCalledTimes(1); - expect(NumberArrayToString(captureEnvelopeSpy.mock.calls[0][0].envelope)).toMatch( - `${expectedHeader}\n${expectedItem}\n${expectedPayload}\n`); - }); - - test('Clears breadcrumbs on Android if there is a handled exception', async () => { + test('Keep breadcrumbs on Android if there is a handled exception', async () => { NATIVE.platform = 'android'; const event = { @@ -366,11 +255,11 @@ describe('Tests Native Wrapper', () => { const expectedItem = JSON.stringify({ type: 'event', content_type: 'application/json', - length: 172, + length: 192, }); const expectedPayload = JSON.stringify({ ...event, - breadcrumbs: [], + breadcrumbs: [{ "message": "crumb!" }], message: { message: event.message, }, @@ -394,6 +283,8 @@ describe('Tests Native Wrapper', () => { expect(SentryCapacitor.captureEnvelope).toBeCalledTimes(1); expect(NumberArrayToString(captureEnvelopeSpy.mock.calls[0][0].envelope)).toMatch( `${expectedHeader}\n${expectedItem}\n${expectedPayload}\n`); + + captureEnvelopeSpy.mockRestore(); }); test('has statusCode 200 on success', async () => { @@ -444,6 +335,7 @@ describe('Tests Native Wrapper', () => { NATIVE.enableNative = true; const result = await NATIVE.sendEnvelope(env); expect(result).toMatchObject(expectedReturn); + captureEnvelopeSpy.mockRestore(); }) }); @@ -459,13 +351,13 @@ describe('Tests Native Wrapper', () => { // }); describe('isModuleLoaded', () => { - test('returns true when module is loaded', () => { + test('returns true when module is loaded', async () => { expect(NATIVE.isModuleLoaded()).toBe(true); }); }); describe('crash', () => { - test('calls the native crash', () => { + test('calls the native crash', async () => { NATIVE.crash(); expect(SentryCapacitor.crash).toBeCalled(); @@ -615,14 +507,16 @@ describe('Tests Native Wrapper', () => { expect(SentryCapacitor.fetchNativeDeviceContexts).toBeCalled(); }); - test('returns empty object on android', async () => { + test('returns context object from native module on android', async () => { NATIVE.platform = 'android'; - await expect(NATIVE.fetchNativeDeviceContexts()).resolves.toMatchObject( - {} - ); + await expect(NATIVE.fetchNativeDeviceContexts()).resolves.toMatchObject({ + someContext: { + someValue: 0, + }, + }); - expect(SentryCapacitor.fetchNativeDeviceContexts).not.toBeCalled(); + expect(SentryCapacitor.fetchNativeDeviceContexts).toBeCalled(); }); }); });