mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(mobile): surface previous crash on relaunch
This commit is contained in:
@@ -8,6 +8,7 @@ import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { MobileRootErrorBoundary } from '../src/components/MobileRootErrorBoundary'
|
||||
import { PreviousCrashSessionLaunchNotice } from '../src/components/PreviousCrashSessionLaunchNotice'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
|
||||
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
|
||||
@@ -184,6 +185,7 @@ function RootLayoutContents() {
|
||||
<RpcClientProvider>
|
||||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||||
<StatusBar style="light" />
|
||||
<PreviousCrashSessionLaunchNotice />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
|
||||
@@ -213,7 +213,7 @@ export default function TroubleshootScreen() {
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{hasPreviousCrashSession && <PreviousCrashSessionBanner />}
|
||||
{hasPreviousCrashSession && <PreviousCrashSessionBanner style={styles.crashBanner} />}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
@@ -320,6 +320,7 @@ export default function TroubleshootScreen() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
crashBanner: { marginBottom: spacing.lg },
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import { AlertTriangle } from 'lucide-react-native'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import { AlertTriangle, ChevronRight, X } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export function PreviousCrashSessionBanner() {
|
||||
export function PreviousCrashSessionBanner({
|
||||
onDismiss,
|
||||
onPress,
|
||||
style
|
||||
}: {
|
||||
onDismiss?: () => void
|
||||
onPress?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const copy = (
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>Previous session ended abnormally</Text>
|
||||
<Text style={styles.description}>
|
||||
Crash diagnostics are available to copy and share with support.
|
||||
</Text>
|
||||
{onPress ? <Text style={styles.actionText}>View diagnostics</Text> : null}
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.banner} testID="previous-crash-session-banner">
|
||||
<View style={[styles.banner, style]} testID="previous-crash-session-banner">
|
||||
<AlertTriangle size={16} color={colors.statusAmber} />
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>Previous session ended abnormally</Text>
|
||||
<Text style={styles.description}>
|
||||
Crash diagnostics are available to copy and share with support.
|
||||
</Text>
|
||||
</View>
|
||||
{onPress ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View crash diagnostics"
|
||||
onPress={onPress}
|
||||
style={styles.open}
|
||||
>
|
||||
{copy}
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
) : (
|
||||
copy
|
||||
)}
|
||||
{onDismiss ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss crash diagnostics notice"
|
||||
hitSlop={spacing.sm}
|
||||
onPress={onDismiss}
|
||||
style={styles.dismiss}
|
||||
>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +58,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
@@ -32,6 +67,15 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
gap: spacing.xs
|
||||
},
|
||||
open: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
dismiss: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
title: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
@@ -41,5 +85,10 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18
|
||||
},
|
||||
actionText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import {
|
||||
dismissPreviousMobileCrashSession,
|
||||
getUndismissedPreviousMobileCrashSession
|
||||
} from '../diagnostics/mobile-crash-diagnostics'
|
||||
import type { MobileCrashSessionSnapshot } from '../diagnostics/mobile-crash-session'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { PreviousCrashSessionBanner } from './PreviousCrashSessionBanner'
|
||||
|
||||
export function PreviousCrashSessionLaunchNotice() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [previousCrash, setPreviousCrash] = useState<MobileCrashSessionSnapshot | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void getUndismissedPreviousMobileCrashSession().then((session) => {
|
||||
if (active) {
|
||||
setPreviousCrash(session)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (!previousCrash) {
|
||||
return
|
||||
}
|
||||
setPreviousCrash(null)
|
||||
void dismissPreviousMobileCrashSession(previousCrash.openedAt)
|
||||
}, [previousCrash])
|
||||
|
||||
const openTroubleshooting = useCallback(() => {
|
||||
dismiss()
|
||||
router.push('/troubleshoot')
|
||||
}, [dismiss, router])
|
||||
|
||||
if (!previousCrash) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<PreviousCrashSessionBanner onDismiss={dismiss} onPress={openTroubleshooting} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
getUndismissed: vi.fn(),
|
||||
push: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 20 })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
AlertTriangle: 'AlertTriangle',
|
||||
ChevronRight: 'ChevronRight',
|
||||
X: 'X'
|
||||
}))
|
||||
vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({
|
||||
dismissPreviousMobileCrashSession: mocks.dismiss,
|
||||
getUndismissedPreviousMobileCrashSession: mocks.getUndismissed
|
||||
}))
|
||||
|
||||
import { PreviousCrashSessionLaunchNotice } from './PreviousCrashSessionLaunchNotice'
|
||||
|
||||
const previousCrash = {
|
||||
openedAt: '2026-08-24T18:00:00.000Z',
|
||||
breadcrumbs: []
|
||||
}
|
||||
|
||||
describe('PreviousCrashSessionLaunchNotice', () => {
|
||||
beforeEach(() => {
|
||||
mocks.dismiss.mockClear()
|
||||
mocks.getUndismissed.mockReset()
|
||||
mocks.push.mockClear()
|
||||
})
|
||||
|
||||
it('shows an abnormal-session report on launch and opens Troubleshooting', async () => {
|
||||
mocks.getUndismissed.mockResolvedValue(previousCrash)
|
||||
let renderer!: ReactTestRenderer
|
||||
await act(async () => {
|
||||
renderer = create(createElement(PreviousCrashSessionLaunchNotice))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(renderer.root.findByProps({ testID: 'previous-crash-session-banner' })).toBeDefined()
|
||||
const open = renderer.root.findByProps({ accessibilityLabel: 'View crash diagnostics' })
|
||||
await act(async () => open.props.onPress())
|
||||
|
||||
expect(mocks.dismiss).toHaveBeenCalledWith(previousCrash.openedAt)
|
||||
expect(mocks.push).toHaveBeenCalledWith('/troubleshoot')
|
||||
})
|
||||
|
||||
it('does not add a launch surface when there is no report', async () => {
|
||||
mocks.getUndismissed.mockResolvedValue(null)
|
||||
let renderer!: ReactTestRenderer
|
||||
await act(async () => {
|
||||
renderer = create(createElement(PreviousCrashSessionLaunchNotice))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(renderer.root.findAllByProps({ testID: 'previous-crash-session-banner' })).toHaveLength(
|
||||
0
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,14 @@ export function getPreviousMobileCrashSession(): Promise<MobileCrashSessionSnaps
|
||||
return journal.getLatestAbnormalSession()
|
||||
}
|
||||
|
||||
export function getUndismissedPreviousMobileCrashSession(): Promise<MobileCrashSessionSnapshot | null> {
|
||||
return journal.getUndismissedLatestAbnormalSession()
|
||||
}
|
||||
|
||||
export function dismissPreviousMobileCrashSession(openedAt: string): Promise<void> {
|
||||
return journal.dismissLatestAbnormalSession(openedAt)
|
||||
}
|
||||
|
||||
export function buildMobileCrashDiagnosticsReport(): Promise<string> {
|
||||
return journal.buildReport({
|
||||
version: Constants.expoConfig?.version ?? 'unknown',
|
||||
|
||||
@@ -23,6 +23,7 @@ export type PersistedMobileCrashJournal = {
|
||||
version: 1
|
||||
activeSession: PersistedMobileCrashSession
|
||||
latestAbnormalSession?: MobileCrashSessionSnapshot
|
||||
dismissedAbnormalSessionOpenedAt?: string
|
||||
}
|
||||
|
||||
export const MOBILE_CRASH_SESSION_STORAGE_KEY = 'orca.mobile-crash-session.v1'
|
||||
@@ -52,10 +53,15 @@ export function parseMobileCrashJournal(raw: string): PersistedMobileCrashJourna
|
||||
return null
|
||||
}
|
||||
const latestAbnormalSession = parseSnapshot(parsed.latestAbnormalSession)
|
||||
const dismissedAbnormalSessionOpenedAt =
|
||||
typeof parsed.dismissedAbnormalSessionOpenedAt === 'string'
|
||||
? sanitizeCrashReportString(parsed.dismissedAbnormalSessionOpenedAt, 80)
|
||||
: null
|
||||
return {
|
||||
version: 1,
|
||||
activeSession,
|
||||
...(latestAbnormalSession ? { latestAbnormalSession } : {})
|
||||
...(latestAbnormalSession ? { latestAbnormalSession } : {}),
|
||||
...(dismissedAbnormalSessionOpenedAt ? { dismissedAbnormalSessionOpenedAt } : {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -76,6 +82,9 @@ export function serializeMobileCrashJournal(journal: PersistedMobileCrashJournal
|
||||
breadcrumbs: [...journal.latestAbnormalSession.breadcrumbs]
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(journal.dismissedAbnormalSessionOpenedAt
|
||||
? { dismissedAbnormalSessionOpenedAt: journal.dismissedAbnormalSessionOpenedAt }
|
||||
: {})
|
||||
}
|
||||
let serialized = JSON.stringify(bounded)
|
||||
|
||||
@@ -149,11 +149,50 @@ describe('mobile crash session journal', () => {
|
||||
})
|
||||
|
||||
await expect(second.start()).resolves.toBeNull()
|
||||
await expect(second.getUndismissedLatestAbnormalSession()).resolves.toBeNull()
|
||||
await expect(
|
||||
second.buildReport({ version: '0.0.29', platform: 'android 15' })
|
||||
).resolves.toContain('No previous abnormal session recorded.')
|
||||
})
|
||||
|
||||
it('keeps a report dismissed across a clean relaunch without hiding the next crash', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const first = new MobileCrashSessionJournal(storage, {
|
||||
now: () => FIRST_SESSION_AT,
|
||||
createSessionId: () => 'session-one'
|
||||
})
|
||||
await first.start()
|
||||
|
||||
const second = new MobileCrashSessionJournal(storage, {
|
||||
now: () => SECOND_SESSION_AT,
|
||||
createSessionId: () => 'session-two'
|
||||
})
|
||||
const previous = await second.start()
|
||||
expect(previous).not.toBeNull()
|
||||
await expect(second.getUndismissedLatestAbnormalSession()).resolves.toEqual(previous)
|
||||
|
||||
await second.dismissLatestAbnormalSession(previous?.openedAt ?? '')
|
||||
await second.recordAppState('background')
|
||||
|
||||
const third = new MobileCrashSessionJournal(storage, {
|
||||
now: () => SECOND_SESSION_AT + 30_000,
|
||||
createSessionId: () => 'session-three'
|
||||
})
|
||||
await third.start()
|
||||
await expect(third.getUndismissedLatestAbnormalSession()).resolves.toBeNull()
|
||||
|
||||
await third.recordRenderError(new Error('a different crash'))
|
||||
await third.recordAppState('background')
|
||||
const fourth = new MobileCrashSessionJournal(storage, {
|
||||
now: () => SECOND_SESSION_AT + 60_000,
|
||||
createSessionId: () => 'session-four'
|
||||
})
|
||||
await fourth.start()
|
||||
await expect(fourth.getUndismissedLatestAbnormalSession()).resolves.toMatchObject({
|
||||
openedAt: new Date(SECOND_SESSION_AT + 30_000).toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
it('reopens the marker when a backgrounded process becomes active again', async () => {
|
||||
const storage = new MemoryStorage()
|
||||
const first = new MobileCrashSessionJournal(storage, {
|
||||
|
||||
@@ -102,6 +102,9 @@ export class MobileCrashSessionJournal {
|
||||
activeSession,
|
||||
...(abnormal || stored?.latestAbnormalSession
|
||||
? { latestAbnormalSession: abnormal ?? stored?.latestAbnormalSession }
|
||||
: {}),
|
||||
...(stored?.dismissedAbnormalSessionOpenedAt
|
||||
? { dismissedAbnormalSessionOpenedAt: stored.dismissedAbnormalSessionOpenedAt }
|
||||
: {})
|
||||
}
|
||||
await this.persistJournal()
|
||||
@@ -146,6 +149,27 @@ export class MobileCrashSessionJournal {
|
||||
return this.enqueue(async () => this.journal?.latestAbnormalSession ?? null)
|
||||
}
|
||||
|
||||
async getUndismissedLatestAbnormalSession(): Promise<MobileCrashSessionSnapshot | null> {
|
||||
await this.start()
|
||||
return this.enqueue(async () => {
|
||||
const latest = this.journal?.latestAbnormalSession
|
||||
return latest?.openedAt === this.journal?.dismissedAbnormalSessionOpenedAt
|
||||
? null
|
||||
: (latest ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
async dismissLatestAbnormalSession(openedAt: string): Promise<void> {
|
||||
await this.start()
|
||||
await this.enqueue(async () => {
|
||||
if (!this.journal || this.journal.latestAbnormalSession?.openedAt !== openedAt) {
|
||||
return
|
||||
}
|
||||
this.journal.dismissedAbnormalSessionOpenedAt = openedAt
|
||||
await this.persistJournal()
|
||||
})
|
||||
}
|
||||
|
||||
async buildReport(app: { version: string; platform: string }): Promise<string> {
|
||||
await this.start()
|
||||
return this.enqueue(async () => {
|
||||
|
||||
Reference in New Issue
Block a user