| 1 | /** |
| 2 | * The shape of an expo-updates log entry we care about. Mirrors |
| 3 | * `UpdatesLogEntry` structurally so the model needs no native module at test |
| 4 | * time. |
| 5 | */ |
| 6 | export interface UpdatesLogEntryLike { |
| 7 | readonly timestamp: number; |
| 8 | readonly message: string; |
| 9 | readonly code: string; |
| 10 | readonly level: string; |
| 11 | } |
| 12 | |
| 13 | /** One fatal JavaScript error that took the app down at startup. */ |
| 14 | export interface StartupCrashRecord { |
| 15 | readonly timestamp: number; |
| 16 | /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */ |
| 17 | readonly description: string; |
| 18 | /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */ |
| 19 | readonly frames: ReadonlyArray<string>; |
| 20 | /** Everything after the message header, verbatim, for copying. */ |
| 21 | readonly detail: string; |
| 22 | } |
| 23 | |
| 24 | const FATAL_PREFIX = "ErrorRecovery fatal exception: "; |
| 25 | const DESCRIPTION_PREFIX = "Description: "; |
| 26 | const UNHANDLED_PREFIX = "Unhandled JS Exception: "; |
| 27 | |
| 28 | /** |
| 29 | * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while |
| 30 | * aborting the process. Everything else in that log (update checks, asset |
| 31 | * loads) is noise for a "why did the app crash" question. |
| 32 | */ |
| 33 | export function parseStartupCrashRecords( |
| 34 | entries: ReadonlyArray<UpdatesLogEntryLike>, |
| 35 | ): ReadonlyArray<StartupCrashRecord> { |
| 36 | const records: StartupCrashRecord[] = []; |
| 37 | for (const entry of entries) { |
| 38 | if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue; |
| 39 | const body = entry.message.slice(FATAL_PREFIX.length); |
| 40 | const lines = body.split("\n"); |
| 41 | const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX)); |
| 42 | if (descriptionLine === undefined) continue; |
| 43 | let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim(); |
| 44 | if (description.startsWith(UNHANDLED_PREFIX)) { |
| 45 | description = description.slice(UNHANDLED_PREFIX.length); |
| 46 | } |
| 47 | const frames = lines |
| 48 | .map((line) => line.trim()) |
| 49 | .filter((line) => line.startsWith("at ")) |
| 50 | .map(compactFrame); |
| 51 | records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() }); |
| 52 | } |
| 53 | // Newest first; the log appends in time order. |
| 54 | return records.sort((left, right) => right.timestamp - left.timestamp); |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as |
| 59 | * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is |
| 60 | * the same on every frame and says nothing. |
| 61 | */ |
| 62 | function compactFrame(line: string): string { |
| 63 | const match = /^at (.+?) \((.*)\)$/.exec(line); |
| 64 | if (!match) return line.slice("at ".length); |
| 65 | const [, name = "", location = ""] = match; |
| 66 | const file = location.slice(location.lastIndexOf("/") + 1); |
| 67 | return location === "<anonymous>" ? name : `${name} (${file})`; |
| 68 | } |
| 69 | |
| 70 | /** The report a user pastes into an issue: every recorded crash, newest first. */ |
| 71 | export function formatStartupCrashReport( |
| 72 | records: ReadonlyArray<StartupCrashRecord>, |
| 73 | app: { readonly version: string; readonly build: string }, |
| 74 | ): string { |
| 75 | const header = `The Knife ${app.version} (${app.build})`; |
| 76 | if (records.length === 0) return `${header}\nNo startup crashes recorded.`; |
| 77 | return [ |
| 78 | header, |
| 79 | ...records.map( |
| 80 | (record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`, |
| 81 | ), |
| 82 | ].join("\n"); |
| 83 | } |