| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model"; |
| 4 | |
| 5 | // Verbatim shape of the entry expo-updates wrote for the build 56 launch crash. |
| 6 | const BUNDLE = |
| 7 | "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle"; |
| 8 | const FATAL = { |
| 9 | timestamp: 1789277752000, |
| 10 | level: "error", |
| 11 | code: "JSRuntimeError", |
| 12 | message: [ |
| 13 | "ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930", |
| 14 | "Domain: RCTErrorDomain", |
| 15 | "Code: 0", |
| 16 | "Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null", |
| 17 | "", |
| 18 | "This error is located at:", |
| 19 | ` at NewTaskFlowProvider (${BUNDLE}:590585:51)`, |
| 20 | ` at NavigationProvider (${BUNDLE}:139067:3)`, |
| 21 | " at RNSSafeAreaView (<anonymous>)", |
| 22 | ].join("\n"), |
| 23 | }; |
| 24 | |
| 25 | describe("parseStartupCrashRecords", () => { |
| 26 | it("extracts the JS error and a compact component stack from a fatal entry", () => { |
| 27 | const [record] = parseStartupCrashRecords([FATAL]); |
| 28 | expect(record?.description).toBe( |
| 29 | "TypeError: Cannot read property 'defaultModelSelection' of null", |
| 30 | ); |
| 31 | expect(record?.frames).toEqual([ |
| 32 | "NewTaskFlowProvider (main.jsbundle:590585:51)", |
| 33 | "NavigationProvider (main.jsbundle:139067:3)", |
| 34 | "RNSSafeAreaView", |
| 35 | ]); |
| 36 | expect(record?.detail).toContain("Domain: RCTErrorDomain"); |
| 37 | }); |
| 38 | |
| 39 | it("ignores update-check noise and orders crashes newest first", () => { |
| 40 | const records = parseStartupCrashRecords([ |
| 41 | { timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" }, |
| 42 | { ...FATAL, timestamp: 10 }, |
| 43 | { timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" }, |
| 44 | { ...FATAL, timestamp: 20 }, |
| 45 | ]); |
| 46 | expect(records.map((record) => record.timestamp)).toEqual([20, 10]); |
| 47 | }); |
| 48 | |
| 49 | it("skips a runtime error entry without a description line", () => { |
| 50 | expect( |
| 51 | parseStartupCrashRecords([ |
| 52 | { ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" }, |
| 53 | ]), |
| 54 | ).toEqual([]); |
| 55 | }); |
| 56 | }); |
| 57 | |
| 58 | describe("formatStartupCrashReport", () => { |
| 59 | it("writes the app version and each crash's full detail", () => { |
| 60 | const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), { |
| 61 | version: "1.1.1", |
| 62 | build: "56", |
| 63 | }); |
| 64 | expect(report.startsWith("The Knife 1.1.1 (56)\n")).toBe(true); |
| 65 | expect(report).toContain("2026-09-13T05:35:52.000Z"); |
| 66 | expect(report).toContain("at NewTaskFlowProvider"); |
| 67 | }); |
| 68 | |
| 69 | it("says so when nothing was recorded", () => { |
| 70 | expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe( |
| 71 | "The Knife 1.1.1 (56)\nNo startup crashes recorded.", |
| 72 | ); |
| 73 | }); |
| 74 | }); |