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 */
6export 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. */
14export 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
24const FATAL_PREFIX = "ErrorRecovery fatal exception: ";
25const DESCRIPTION_PREFIX = "Description: ";
26const 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 */
33export 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 */
62function 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. */
71export 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}