|
1 | 1 | import { beforeEach, describe, expect, test, vi } from "vitest"; |
2 | 2 |
|
3 | 3 | import { ExceptionlessClient } from "@exceptionless/core"; |
| 4 | +import type { CrashReport, ExceptionlessNativeModuleInterface } from "../../src/native/ExceptionlessNativeModule.js"; |
4 | 5 | import { NativeCrashPlugin } from "../../src/plugins/NativeCrashPlugin.js"; |
5 | 6 |
|
6 | | -// Mock the native module |
| 7 | +const nativeModuleMock = vi.hoisted(() => ({ |
| 8 | + current: null as ExceptionlessNativeModuleInterface | null |
| 9 | +})); |
| 10 | + |
7 | 11 | vi.mock("../../src/native/ExceptionlessNativeModule.js", () => ({ |
8 | | - getNativeModule: () => null, |
9 | | - isNativeModuleAvailable: () => false |
| 12 | + getNativeModule: () => nativeModuleMock.current, |
| 13 | + isNativeModuleAvailable: () => nativeModuleMock.current !== null |
10 | 14 | })); |
11 | 15 |
|
12 | 16 | describe("NativeCrashPlugin", () => { |
13 | 17 | let plugin: NativeCrashPlugin; |
14 | 18 | let client: ExceptionlessClient; |
15 | 19 |
|
16 | 20 | beforeEach(() => { |
| 21 | + nativeModuleMock.current = null; |
| 22 | + vi.clearAllMocks(); |
| 23 | + |
17 | 24 | plugin = new NativeCrashPlugin(); |
18 | 25 | client = new ExceptionlessClient(); |
19 | 26 | client.config.apiKey = "UNIT_TEST_API_KEY"; |
@@ -55,4 +62,125 @@ describe("NativeCrashPlugin", () => { |
55 | 62 | warnSpy.mockRestore(); |
56 | 63 | Object.defineProperty(Platform, "OS", { value: "ios", writable: true }); |
57 | 64 | }); |
| 65 | + |
| 66 | + test("should submit pending native crash reports and clear them after successful submission", async () => { |
| 67 | + const { Platform } = await import("react-native"); |
| 68 | + Object.defineProperty(Platform, "OS", { value: "ios", writable: true }); |
| 69 | + |
| 70 | + const report = createCrashReport(); |
| 71 | + const nativeModule = createNativeModule([report]); |
| 72 | + nativeModuleMock.current = nativeModule; |
| 73 | + |
| 74 | + const submit = vi.fn().mockResolvedValue(undefined); |
| 75 | + const setProperty = vi.fn().mockReturnThis(); |
| 76 | + const createUnhandledException = vi.spyOn(client, "createUnhandledException").mockReturnValue({ |
| 77 | + setProperty, |
| 78 | + submit |
| 79 | + } as never); |
| 80 | + |
| 81 | + await plugin.startup({ client, log: client.config.services.log }); |
| 82 | + |
| 83 | + expect(nativeModule.install).toHaveBeenCalledOnce(); |
| 84 | + expect(nativeModule.hasPendingCrashReport).toHaveBeenCalledOnce(); |
| 85 | + expect(nativeModule.getPendingCrashReports).toHaveBeenCalledOnce(); |
| 86 | + expect(createUnhandledException).toHaveBeenCalledOnce(); |
| 87 | + |
| 88 | + const [error, source] = createUnhandledException.mock.calls[0]; |
| 89 | + expect(source).toBe("NativeCrashReporter"); |
| 90 | + expect(error).toBeInstanceOf(Error); |
| 91 | + expect(error.name).toBe("NSInvalidArgumentException"); |
| 92 | + expect(error.message).toBe("Native crash from unit test"); |
| 93 | + expect(error.stack).toContain(" at -[CrashyViewController crash] (unknown+42)"); |
| 94 | + expect(error.stack).toContain(" at 0x100000abc (ExceptionlessExpoExample)"); |
| 95 | + |
| 96 | + expect(setProperty).toHaveBeenCalledWith("native_crash", { |
| 97 | + signal_name: "SIGABRT", |
| 98 | + signal_code: "0", |
| 99 | + exception_type: "NSException", |
| 100 | + crashed_thread: 3, |
| 101 | + device: report.device, |
| 102 | + timestamp: report.timestamp |
| 103 | + }); |
| 104 | + expect(submit).toHaveBeenCalledOnce(); |
| 105 | + expect(nativeModule.clearPendingCrashReports).toHaveBeenCalledOnce(); |
| 106 | + expect(submit.mock.invocationCallOrder[0]).toBeLessThan(nativeModule.clearPendingCrashReports.mock.invocationCallOrder[0]); |
| 107 | + }); |
| 108 | + |
| 109 | + test("should not clear pending native crash reports when a pending marker returns no reports", async () => { |
| 110 | + const { Platform } = await import("react-native"); |
| 111 | + Object.defineProperty(Platform, "OS", { value: "ios", writable: true }); |
| 112 | + |
| 113 | + const nativeModule = createNativeModule([]); |
| 114 | + nativeModuleMock.current = nativeModule; |
| 115 | + |
| 116 | + const warnSpy = vi.spyOn(client.config.services.log, "warn"); |
| 117 | + const createUnhandledException = vi.spyOn(client, "createUnhandledException"); |
| 118 | + |
| 119 | + await plugin.startup({ client, log: client.config.services.log }); |
| 120 | + |
| 121 | + expect(nativeModule.install).toHaveBeenCalledOnce(); |
| 122 | + expect(nativeModule.hasPendingCrashReport).toHaveBeenCalledOnce(); |
| 123 | + expect(nativeModule.getPendingCrashReports).toHaveBeenCalledOnce(); |
| 124 | + expect(createUnhandledException).not.toHaveBeenCalled(); |
| 125 | + expect(nativeModule.clearPendingCrashReports).not.toHaveBeenCalled(); |
| 126 | + expect(warnSpy).toHaveBeenCalledWith("Native crash reporter indicated a pending crash, but no crash reports were returned."); |
| 127 | + }); |
58 | 128 | }); |
| 129 | + |
| 130 | +function createNativeModule(reports: CrashReport[], hasPending = true) { |
| 131 | + return { |
| 132 | + install: vi.fn(), |
| 133 | + hasPendingCrashReport: vi.fn().mockResolvedValue(hasPending), |
| 134 | + getPendingCrashReports: vi.fn().mockResolvedValue(reports), |
| 135 | + clearPendingCrashReports: vi.fn().mockResolvedValue(undefined) |
| 136 | + }; |
| 137 | +} |
| 138 | + |
| 139 | +function createCrashReport(): CrashReport { |
| 140 | + return { |
| 141 | + timestamp: "2026-05-31T03:00:00.000Z", |
| 142 | + signal_name: "SIGABRT", |
| 143 | + signal_code: "0", |
| 144 | + exception_type: "NSException", |
| 145 | + exception_name: "NSInvalidArgumentException", |
| 146 | + exception_reason: "Native crash from unit test", |
| 147 | + crashed_thread: 3, |
| 148 | + threads: [ |
| 149 | + { |
| 150 | + thread_id: 1, |
| 151 | + crashed: false, |
| 152 | + frames: [ |
| 153 | + { |
| 154 | + address: "0x100000001", |
| 155 | + image: "UIKit", |
| 156 | + symbol: "UIApplicationMain", |
| 157 | + offset: 12 |
| 158 | + } |
| 159 | + ] |
| 160 | + }, |
| 161 | + { |
| 162 | + thread_id: 3, |
| 163 | + crashed: true, |
| 164 | + frames: [ |
| 165 | + { |
| 166 | + address: "0x100000123", |
| 167 | + image: null, |
| 168 | + symbol: "-[CrashyViewController crash]", |
| 169 | + offset: 42 |
| 170 | + }, |
| 171 | + { |
| 172 | + address: "0x100000abc", |
| 173 | + image: "ExceptionlessExpoExample", |
| 174 | + symbol: null, |
| 175 | + offset: null |
| 176 | + } |
| 177 | + ] |
| 178 | + } |
| 179 | + ], |
| 180 | + device: { |
| 181 | + model: "iPad Pro 11-inch", |
| 182 | + os_version: "iOS 18.0", |
| 183 | + app_version: "1.0.0" |
| 184 | + } |
| 185 | + }; |
| 186 | +} |
0 commit comments