From c87599221a586640c7453b68c7bd3835f778cd89 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 09:39:07 +0200 Subject: [PATCH 01/17] feat: refactor crash detection around app sessions Introduce AppSession lifecycle ownership, classify bridge disconnects via cached session state, and attach scoped session log evidence to runtime failures. --- .../jest/src/__tests__/crash-monitor.test.ts | 345 ++++++------------ packages/jest/src/crash-monitor.ts | 270 +++++++------- packages/jest/src/errors.ts | 45 +++ packages/jest/src/execute-run.ts | 5 +- packages/jest/src/harness-session.ts | 77 ++-- .../src/__tests__/instance.test.ts | 30 +- packages/platform-android/src/instance.ts | 178 ++++----- .../__tests__/instance-xctest-agent.test.ts | 10 +- .../src/__tests__/instance.test.ts | 11 +- packages/platform-ios/src/app-session.ts | 133 +++++++ packages/platform-ios/src/instance.ts | 99 ++--- packages/platform-ios/src/xcrun/devicectl.ts | 16 +- packages/platform-ios/src/xcrun/simctl.ts | 19 + packages/platform-vega/src/runner.ts | 120 +++--- packages/platform-web/package.json | 1 - packages/platform-web/src/runner.ts | 119 ++---- packages/platform-web/tsconfig.json | 3 - packages/platform-web/tsconfig.lib.json | 3 - packages/platforms/README.md | 46 ++- packages/platforms/src/index.ts | 10 + packages/platforms/src/session.ts | 60 +++ packages/platforms/src/types.ts | 42 ++- 22 files changed, 861 insertions(+), 781 deletions(-) create mode 100644 packages/platform-ios/src/app-session.ts create mode 100644 packages/platforms/src/session.ts diff --git a/packages/jest/src/__tests__/crash-monitor.test.ts b/packages/jest/src/__tests__/crash-monitor.test.ts index 92a6be3e..e1b5d645 100644 --- a/packages/jest/src/__tests__/crash-monitor.test.ts +++ b/packages/jest/src/__tests__/crash-monitor.test.ts @@ -1,288 +1,157 @@ import { describe, expect, it, vi } from 'vitest'; import type { - AppCrashDetails, - AppMonitor, - AppMonitorEvent, - AppMonitorListener, - HarnessPlatformRunner, + AppSession, + AppSessionEvent, + AppSessionListener, + AppSessionLog, + AppSessionState, } from '@react-native-harness/platforms'; import { createCrashMonitor, CrashWatchCancelledError, } from '../crash-monitor.js'; -import { NativeCrashError } from '../errors.js'; +import { NativeCrashError, RuntimeDisconnectError } from '../errors.js'; const noop = () => undefined; -const resolveUndefined = async () => undefined; - -// --------------------------------------------------------------------------- -// Test doubles -// --------------------------------------------------------------------------- - -const createAppMonitorMock = () => { - let registeredListener: AppMonitorListener | null = null; - - const monitor: AppMonitor = { - start: vi.fn(resolveUndefined), - stop: vi.fn(resolveUndefined), - dispose: vi.fn(resolveUndefined), - addListener: vi.fn((l: AppMonitorListener) => { - registeredListener = l; +const waitForClassification = () => + new Promise((resolve) => setTimeout(resolve, 350)); + +const createAppSessionMock = ( + initialState: AppSessionState = { status: 'running' }, + logs: AppSessionLog[] = [], +) => { + let state = initialState; + let listener: AppSessionListener | null = null; + + const session: AppSession = { + dispose: vi.fn(async () => undefined), + getState: vi.fn(async () => state), + getLogs: vi.fn(() => logs), + addListener: vi.fn((l: AppSessionListener) => { + listener = l; }), - removeListener: vi.fn((l: AppMonitorListener) => { - if (registeredListener === l) registeredListener = null; + removeListener: vi.fn((l: AppSessionListener) => { + if (listener === l) listener = null; }), }; return { - monitor, - emit: (event: AppMonitorEvent) => registeredListener?.(event), + session, + setState: (nextState: AppSessionState) => { + state = nextState; + }, + emit: (event: AppSessionEvent) => listener?.(event), }; }; -const createPlatformRunnerMock = ( - isRunning = false, - crashDetails: AppCrashDetails | null = null, -) => - ({ - isAppRunning: vi.fn(async () => isRunning), - getCrashDetails: vi.fn(async () => crashDetails), - startApp: vi.fn(resolveUndefined), - restartApp: vi.fn(resolveUndefined), - stopApp: vi.fn(resolveUndefined), - dispose: vi.fn(resolveUndefined), - createAppMonitor: vi.fn(), - }) as unknown as HarnessPlatformRunner; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('createCrashMonitor', () => { - describe('liveness', () => { - it('starts not alive', () => { - const { monitor } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - expect(cm.isAlive()).toBe(false); - }); + it('starts not alive and becomes alive when an app session is attached', () => { + const cm = createCrashMonitor(); + const { session } = createAppSessionMock(); - it('becomes alive when the app starts', () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); + expect(cm.isAlive()).toBe(false); - emit({ type: 'app_started' }); + cm.setAppSession(session); - expect(cm.isAlive()).toBe(true); - }); - - it('becomes not alive after a confirmed crash', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - emit({ type: 'app_started' }); - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - - emit({ type: 'app_exited', isConfirmed: true }); - await watch.promise.catch(noop); - - expect(cm.isAlive()).toBe(false); - }); + expect(cm.isAlive()).toBe(true); }); - describe('watch', () => { - it('promise rejects with NativeCrashError on confirmed app_exited', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('/test/example.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); + it('rejects a watch with NativeCrashError when the app session exits', async () => { + const { session, emit } = createAppSessionMock({ + status: 'exited', + occurredAt: Date.now(), + reason: 'observed-exit', }); + const cm = createCrashMonitor({ appSession: session }); + const watch = cm.watch('/test/example.ts', 'execution'); + watch.promise.catch(noop); - it('attributes the crash to the file and phase passed to watch()', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('/test/example.ts', 'startup'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - const error = await watch.promise.catch((e: NativeCrashError) => e); - expect(error.testFilePath).toBe('/test/example.ts'); - expect(error.details.phase).toBe('startup'); - }); + emit({ type: 'app_exited' }); - it('settles the promise with CrashWatchCancelledError on cancel()', async () => { - const { monitor } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.cancel(); - - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); - }); - - it('subsequent cancel() after crash is a no-op', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - await watch.promise.catch(noop); - // Second cancel should not throw or cause issues. - expect(() => watch.cancel()).not.toThrow(); - }); + const error = await watch.promise.catch((err: NativeCrashError) => err); + expect(error).toBeInstanceOf(NativeCrashError); + expect(error.testFilePath).toBe('/test/example.ts'); + expect(error.details.phase).toBe('execution'); + expect(cm.isAlive()).toBe(false); }); - describe('unconfirmed events', () => { - it('fires the crash if isAppRunning returns false', async () => { - const { monitor, emit } = createAppMonitorMock(); - const runner = createPlatformRunnerMock(false /* not running */); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: false }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); - - it('does not fire if isAppRunning returns true', async () => { - const { monitor, emit } = createAppMonitorMock(); - const runner = createPlatformRunnerMock(true /* still running */); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner }); - - const watch = cm.watch('test.ts', 'execution'); - const settled = vi.fn(); - watch.promise.then(settled, settled); - - emit({ type: 'app_exited', isConfirmed: false }); - await new Promise((r) => setTimeout(r, 20)); - - expect(settled).not.toHaveBeenCalled(); - watch.cancel(); - }); - - it('fires on possible_crash when confirmed', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); + it('classifies bridge disconnect plus exited app as NativeCrashError', async () => { + const { session, setState } = createAppSessionMock(); + const cm = createCrashMonitor({ appSession: session }); + const watch = cm.watch('test.ts', 'execution'); + watch.promise.catch(noop); - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'possible_crash', isConfirmed: true }); + cm.handleBridgeDisconnect(); + setState({ status: 'exited', occurredAt: Date.now(), reason: 'process-gone' }); - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); + await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); }); - describe('crash detail enrichment', () => { - it('merges initial and enriched crash details', async () => { - const { monitor, emit } = createAppMonitorMock(); - const runner = createPlatformRunnerMock(false, { - processName: 'MyApp', - signal: 'SIGSEGV', - summary: 'Segmentation fault', - }); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: runner }); + it('classifies bridge disconnect plus running app as RuntimeDisconnectError', async () => { + const { session } = createAppSessionMock({ status: 'running' }); + const cm = createCrashMonitor({ appSession: session }); + const watch = cm.watch('test.ts', 'execution'); + watch.promise.catch(noop); - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true, crashDetails: { pid: 1234 } }); + cm.handleBridgeDisconnect(); - const error = await watch.promise.catch((e: NativeCrashError) => e); - expect(error.details.processName).toBe('MyApp'); - expect(error.details.signal).toBe('SIGSEGV'); - expect(error.details.pid).toBe(1234); - }); + await expect(watch.promise).rejects.toBeInstanceOf(RuntimeDisconnectError); }); - describe('stop / start', () => { - it('ignores events while stopped', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - await cm.stop(); - - const watch = cm.watch('test.ts', 'execution'); - const settled = vi.fn(); - watch.promise.then(settled, settled); - - emit({ type: 'app_exited', isConfirmed: true }); - await new Promise((r) => setTimeout(r, 10)); - - expect(settled).not.toHaveBeenCalled(); - watch.cancel(); - }); - - it('resumes monitoring after start()', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - await cm.stop(); - await cm.start(); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - emit({ type: 'app_exited', isConfirmed: true }); - - await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); - }); + it('attaches matching session log evidence to native crash details', async () => { + const occurredAt = Date.now(); + const logs = [ + { line: 'ordinary app log', occurredAt }, + { line: 'MyApp[123] fatal error: boom', occurredAt }, + ]; + const { session, setState } = createAppSessionMock({ status: 'running' }, logs); + const cm = createCrashMonitor({ appSession: session }); + const watch = cm.watch('test.ts', 'execution'); + watch.promise.catch(noop); + + cm.handleBridgeDisconnect(); + setState({ status: 'exited', occurredAt, reason: 'process-gone' }); + + const error = await watch.promise.catch((err: NativeCrashError) => err); + expect(error).toBeInstanceOf(NativeCrashError); + expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']); }); - describe('reset', () => { - it('clears alive state and pending watchers', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - emit({ type: 'app_started' }); - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); - - cm.reset(); + it('settles the promise with CrashWatchCancelledError on cancel()', async () => { + const cm = createCrashMonitor(); + const watch = cm.watch('test.ts', 'execution'); - expect(cm.isAlive()).toBe(false); - // The watcher was cleared; a crash fired now should not reach the old watch. - emit({ type: 'app_exited', isConfirmed: true }); - await new Promise((r) => setTimeout(r, 10)); + watch.cancel(); - // Old promise is still pending (we can verify by cancel resolving it). - watch.cancel(); - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); - }); + await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); }); - describe('dispose', () => { - it('ignores events after dispose', async () => { - const { monitor, emit } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); - - const watch = cm.watch('test.ts', 'execution'); - watch.promise.catch(noop); + it('ignores session events while stopped', async () => { + const { session, emit } = createAppSessionMock({ + status: 'exited', + occurredAt: Date.now(), + }); + const cm = createCrashMonitor({ appSession: session }); + await cm.stop(); - await cm.dispose(); + const watch = cm.watch('test.ts', 'execution'); + const settled = vi.fn(); + watch.promise.then(settled, settled); - emit({ type: 'app_exited', isConfirmed: true }); - await new Promise((r) => setTimeout(r, 10)); + emit({ type: 'app_exited' }); + await waitForClassification(); - // After dispose watchers are cleared, so crash didn't propagate. - // The promise is still pending - cancel to settle it. - watch.cancel(); - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); - }); + expect(settled).not.toHaveBeenCalled(); + watch.cancel(); + }); - it('calls appMonitor.dispose()', async () => { - const { monitor } = createAppMonitorMock(); - const cm = createCrashMonitor({ appMonitor: monitor, platformRunner: createPlatformRunnerMock() }); + it('removes the app session listener on dispose', async () => { + const { session } = createAppSessionMock(); + const cm = createCrashMonitor({ appSession: session }); - await cm.dispose(); + await cm.dispose(); - expect(monitor.dispose).toHaveBeenCalledOnce(); - }); + expect(session.removeListener).toHaveBeenCalledOnce(); + expect(cm.isAlive()).toBe(false); }); }); diff --git a/packages/jest/src/crash-monitor.ts b/packages/jest/src/crash-monitor.ts index 46807d1a..cecd202a 100644 --- a/packages/jest/src/crash-monitor.ts +++ b/packages/jest/src/crash-monitor.ts @@ -1,18 +1,22 @@ import { - type AppMonitor, - type AppCrashDetails, - type AppMonitorEvent, - type AppMonitorListener, - type HarnessPlatformRunner, + type AppSession, + type AppSessionEvent, + type AppSessionListener, + type AppSessionLog, } from '@react-native-harness/platforms'; import { NativeCrashError, + RuntimeDisconnectError, + type HarnessRuntimeFailure, type NativeCrashDetails, type NativeCrashPhase, + type RuntimeDisconnectDetails, } from './errors.js'; import { logger } from '@react-native-harness/tools'; const crashLogger = logger.child('crash'); +const CRASH_CLASSIFICATION_SETTLE_MS = 300; +const CRASH_LOG_WINDOW_MS = 3000; export class CrashWatchCancelledError extends Error { constructor() { @@ -32,172 +36,167 @@ export type CrashMonitor = { stop: () => Promise; start: () => Promise; reset: () => void; + setAppSession: (session: AppSession | null) => void; + handleBridgeDisconnect: () => void; dispose: () => Promise; }; export type CrashMonitorOptions = { - appMonitor: AppMonitor; - platformRunner: HarnessPlatformRunner; + appSession?: AppSession | null; }; -type CrashDetailsProvider = { - getCrashDetails?: (options: { - processName?: string; - pid?: number; - occurredAt: number; - }) => Promise; +type PendingCrash = { + testFilePath: string; + phase: NativeCrashPhase; + occurredAt: number; }; -const mergeCrashDetails = ( +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const isCrashIndicator = (line: string) => + /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason|crash|abort/i.test( + line, + ) || /\bSIG[A-Z]{2,}\b/.test(line); + +const getMatchingCrashLines = ( + logs: AppSessionLog[], + occurredAt: number, +): string[] => + logs + .filter((log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS) + .map((log) => log.line) + .filter(isCrashIndicator); + +const buildNativeCrashDetails = ( phase: NativeCrashPhase, - initial?: AppCrashDetails, - enriched?: AppCrashDetails | null, - fallbackSummary?: string, + rawLines: string[], + summary: string, ): NativeCrashDetails => ({ phase, - source: enriched?.source ?? initial?.source, - summary: enriched?.summary ?? initial?.summary ?? fallbackSummary, - signal: enriched?.signal ?? initial?.signal, - exceptionType: enriched?.exceptionType ?? initial?.exceptionType, - processName: enriched?.processName ?? initial?.processName, - pid: enriched?.pid ?? initial?.pid, - stackTrace: enriched?.stackTrace ?? initial?.stackTrace, - rawLines: enriched?.rawLines ?? initial?.rawLines, - artifactType: enriched?.artifactType ?? initial?.artifactType, - artifactPath: enriched?.artifactPath ?? initial?.artifactPath, + source: rawLines.length > 0 ? 'logs' : 'bridge', + summary: rawLines.length > 0 ? rawLines.join('\n') : summary, + rawLines: rawLines.length > 0 ? rawLines : undefined, +}); + +const buildRuntimeDisconnectDetails = ( + phase: NativeCrashPhase, + rawLines: string[], +): RuntimeDisconnectDetails => ({ + phase, + source: 'bridge', + summary: + 'The runtime bridge disconnected, but the app session still appears to be running.', + rawLines: rawLines.length > 0 ? rawLines : undefined, }); export const createCrashMonitor = ({ - appMonitor, - platformRunner, -}: CrashMonitorOptions): CrashMonitor => { + appSession: initialAppSession = null, +}: CrashMonitorOptions = {}): CrashMonitor => { let alive = false; let monitoring = true; let isResolvingCrash = false; let disposed = false; + let appSession: AppSession | null = null; + let pendingTimer: NodeJS.Timeout | null = null; - // Both updated when watch() is called so crashes are attributed to the - // correct test file and lifecycle phase. let currentTestFilePath = ''; let currentPhase: NativeCrashPhase = 'startup'; - const watchers = new Set<(err: NativeCrashError) => void>(); - - const getCrashDetailsProvider = (): CrashDetailsProvider | null => { - if ('getCrashDetails' in appMonitor) { - return appMonitor as AppMonitor & CrashDetailsProvider; - } - if (platformRunner.getCrashDetails) { - return platformRunner; - } - return null; - }; + const watchers = new Set<(err: HarnessRuntimeFailure) => void>(); - const notifyCrash = (err: NativeCrashError) => { + const notifyFailure = (err: HarnessRuntimeFailure) => { const pending = [...watchers]; watchers.clear(); for (const fn of pending) fn(err); }; - const handleCrash = async ( - phase: NativeCrashPhase, - details?: AppCrashDetails, - fallbackSummary?: string, - ) => { - if (isResolvingCrash) return; - isResolvingCrash = true; - alive = false; - - crashLogger.debug('native crash detected (phase=%s)', phase); - for (const line of details?.rawLines ?? []) { - crashLogger.debug('%s', line); - } - - try { - const enriched = await getCrashDetailsProvider()?.getCrashDetails?.({ - processName: details?.processName, - pid: details?.pid, - occurredAt: Date.now(), - }); - const merged = mergeCrashDetails(phase, details, enriched, fallbackSummary); - crashLogger.debug('crash details: %o', { - phase: merged.phase, - source: merged.source, - summary: merged.summary, - signal: merged.signal, - exceptionType: merged.exceptionType, - processName: merged.processName, - pid: merged.pid, - }); - notifyCrash(new NativeCrashError(currentTestFilePath, merged)); - } finally { - isResolvingCrash = false; + const clearPendingTimer = () => { + if (pendingTimer) { + clearTimeout(pendingTimer); + pendingTimer = null; } }; - const confirmAndHandleCrash = async ( - phase: NativeCrashPhase, - details?: AppCrashDetails, - fallbackSummary?: string, + const classify = async ( + pending: PendingCrash, + trigger: 'bridge-disconnect' | 'app-exit', ) => { - if (disposed || !monitoring) return; + if (disposed || !monitoring || isResolvingCrash) { + return; + } + + isResolvingCrash = true; + try { - const isRunning = await platformRunner.isAppRunning(); - if (!isRunning) { - void handleCrash(phase, details, fallbackSummary); + const session = appSession; + const state = await session?.getState(); + const logs = session?.getLogs() ?? []; + const rawLines = getMatchingCrashLines(logs, pending.occurredAt); + + if (state?.status === 'running' && trigger === 'bridge-disconnect') { + crashLogger.debug('runtime bridge disconnected without confirmed app death'); + notifyFailure( + new RuntimeDisconnectError( + pending.testFilePath, + buildRuntimeDisconnectDetails(pending.phase, rawLines), + ), + ); + return; + } + + alive = false; + crashLogger.debug('native crash detected (phase=%s)', pending.phase); + for (const line of rawLines) { + crashLogger.debug('%s', line); } - } catch (error) { - crashLogger.debug('crash confirmation failed', error); + + const fallbackSummary = + trigger === 'bridge-disconnect' + ? 'The app process exited after the runtime bridge disconnected, but no crash log lines were found.' + : 'The app process exited, but no crash log lines were found.'; + const details = buildNativeCrashDetails( + pending.phase, + rawLines, + fallbackSummary, + ); + notifyFailure(new NativeCrashError(pending.testFilePath, details)); + } finally { + isResolvingCrash = false; + pendingTimer = null; } }; - const extractCrashDetails = ( - event: Extract, - ): AppCrashDetails | undefined => - event.crashDetails - ? { - source: event.crashDetails.source ?? event.source, - summary: event.crashDetails.summary, - signal: event.crashDetails.signal, - exceptionType: event.crashDetails.exceptionType, - processName: event.crashDetails.processName, - pid: event.crashDetails.pid ?? event.pid, - stackTrace: event.crashDetails.stackTrace, - rawLines: - event.crashDetails.rawLines ?? - (event.line ? [event.line] : undefined), - } - : undefined; - - const appMonitorListener: AppMonitorListener = (event: AppMonitorEvent) => { - if (disposed || !monitoring) return; - - if (event.type === 'app_started') { - alive = true; + const startCrashResolution = (trigger: 'bridge-disconnect' | 'app-exit') => { + if (disposed || !monitoring || isResolvingCrash) { return; } + clearPendingTimer(); + const pending: PendingCrash = { + testFilePath: currentTestFilePath, + phase: currentPhase, + occurredAt: Date.now(), + }; + + pendingTimer = setTimeout(() => { + void classify(pending, trigger); + }, trigger === 'bridge-disconnect' ? CRASH_CLASSIFICATION_SETTLE_MS : 0); + }; + + const appSessionListener: AppSessionListener = (event: AppSessionEvent) => { if (event.type === 'app_exited') { - const details = extractCrashDetails(event); - if (event.isConfirmed ?? event.source === 'polling') { - void handleCrash(currentPhase, details); - } else { - void confirmAndHandleCrash(currentPhase, details); - } - return; + startCrashResolution('app-exit'); } + }; - if (event.type === 'possible_crash') { - const details = extractCrashDetails(event); - const fallback = `possible crash signal (${event.source ?? 'unknown'})`; - if (event.isConfirmed) { - void handleCrash(currentPhase, details, fallback); - } else { - void confirmAndHandleCrash(currentPhase, details, fallback); - } - } + const setAppSession = (session: AppSession | null) => { + appSession?.removeListener(appSessionListener); + appSession = session; + alive = Boolean(session); + session?.addListener(appSessionListener); }; - appMonitor.addListener(appMonitorListener); + setAppSession(initialAppSession); const watch = (testFilePath: string, phase: NativeCrashPhase): CrashWatch => { currentTestFilePath = testFilePath; @@ -224,25 +223,32 @@ export const createCrashMonitor = ({ isAlive: () => alive, stop: async () => { monitoring = false; - await appMonitor.stop(); + clearPendingTimer(); + await sleep(0); }, start: async () => { monitoring = true; - await appMonitor.start(); }, reset: () => { - alive = false; + alive = Boolean(appSession); watchers.clear(); isResolvingCrash = false; currentTestFilePath = ''; + clearPendingTimer(); + }, + setAppSession, + handleBridgeDisconnect: () => { + startCrashResolution('bridge-disconnect'); }, dispose: async () => { disposed = true; monitoring = false; watchers.clear(); isResolvingCrash = false; - appMonitor.removeListener(appMonitorListener); - await appMonitor.dispose(); + clearPendingTimer(); + appSession?.removeListener(appSessionListener); + appSession = null; + alive = false; }, }; }; diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index d9d0dc86..26c7d290 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -55,6 +55,10 @@ export type NativeCrashDetails = AppCrashDetails & { phase: NativeCrashPhase; }; +export type RuntimeDisconnectDetails = AppCrashDetails & { + phase: NativeCrashPhase; +}; + const buildNativeCrashMessage = ({ phase, summary, @@ -119,3 +123,44 @@ export class NativeCrashError extends HarnessError { return this.details.phase; } } + +const buildRuntimeDisconnectMessage = ({ + phase, + summary, + rawLines, +}: RuntimeDisconnectDetails) => { + const lines = [ + phase === 'startup' + ? 'The native runtime disconnected while preparing to run this test file.' + : 'The native runtime disconnected during test execution.', + ]; + + if (summary) { + lines.push(''); + lines.push(summary); + } + + if (rawLines && rawLines.length > 0 && summary !== rawLines.join('\n')) { + lines.push(''); + lines.push(...rawLines); + } + + return lines.join('\n'); +}; + +export class RuntimeDisconnectError extends HarnessError { + constructor( + public readonly testFilePath: string, + public readonly details: RuntimeDisconnectDetails + ) { + super(buildRuntimeDisconnectMessage(details)); + this.name = 'RuntimeDisconnectError'; + this.stack = `${this.name}: ${this.message.split('\n')[0]}`; + } + + get phase() { + return this.details.phase; + } +} + +export type HarnessRuntimeFailure = NativeCrashError | RuntimeDisconnectError; diff --git a/packages/jest/src/execute-run.ts b/packages/jest/src/execute-run.ts index 0591adf7..8e721284 100644 --- a/packages/jest/src/execute-run.ts +++ b/packages/jest/src/execute-run.ts @@ -12,6 +12,7 @@ import { type HarnessSession, type HarnessRunState } from './harness-session.js' import { runHarnessTestFile } from './run.js'; import { NativeCrashError, + RuntimeDisconnectError, StartupStallError, } from './errors.js'; import { DeviceNotRespondingError } from '@react-native-harness/bridge/server'; @@ -38,6 +39,7 @@ class CancelRun extends Error { const buildTestFailure = (err: unknown): { message: string; stack: string } => { if ( err instanceof NativeCrashError || + err instanceof RuntimeDisconnectError || err instanceof StartupStallError || err instanceof DeviceNotRespondingError ) { @@ -163,6 +165,7 @@ export const executeRun = async ( const isRuntimeFailure = err instanceof NativeCrashError || + err instanceof RuntimeDisconnectError || err instanceof StartupStallError || err instanceof DeviceNotRespondingError; @@ -171,7 +174,7 @@ export const executeRun = async ( updateRunState(); } - if (err instanceof NativeCrashError) { + if (err instanceof NativeCrashError || err instanceof RuntimeDisconnectError) { session.resetCrashState(); } diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index a19b2387..a215064f 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -12,6 +12,7 @@ import { } from '@react-native-harness/bridge'; import { type AppLaunchOptions, + type AppSession, type HarnessPlatform, type HarnessPlatformInitOptions, type HarnessPlatformRunner, @@ -34,7 +35,6 @@ import { } from '@react-native-harness/plugins'; import { logger, - createCrashArtifactWriter, getTimeoutSignal, raceAbortSignals, } from '@react-native-harness/tools'; @@ -147,13 +147,12 @@ const withPlatformReadyTimeout = async (options: { type AppReadyOptions = { metroInstance: MetroInstance; bridge: HarnessBridge; - platformInstance: HarnessPlatformRunner; platformId: string; bundleStartTimeout: number; readyTimeout: number; maxAppRestarts: number; crashMonitor: CrashMonitor; - appLaunchOptions?: AppLaunchOptions; + restartAppSession: () => Promise; }; const waitForAppReady = async ( @@ -163,13 +162,12 @@ const waitForAppReady = async ( const { metroInstance, bridge, - platformInstance, platformId, bundleStartTimeout, readyTimeout, maxAppRestarts, crashMonitor, - appLaunchOptions, + restartAppSession, } = base; const logWait = (message: string, ...args: unknown[]) => @@ -184,7 +182,7 @@ const waitForAppReady = async ( signal: new AbortController().signal, startAttempt: async () => { logWait('launching app for %s', testFilePath); - await platformInstance.restartApp(appLaunchOptions); + await restartAppSession(); logWait('launch request completed, waiting for bridge ready'); }, waitForReady: async (signal) => { @@ -195,7 +193,7 @@ const waitForAppReady = async ( // connection from a previous run would resolve the promise before startAttempt // even restarts the app — leaving bridge.connection null after the restart. await new Promise((resolve, reject) => { - const onConnected = (_conn: AppConnection) => { cleanup(); resolve(); }; + const onConnected = () => { cleanup(); resolve(); }; const onAbort = () => { cleanup(); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); }; const cleanup = () => { bridge.off('connected', onConnected); @@ -460,27 +458,41 @@ export const createHarnessSession = async ( throw error; } - const crashArtifactWriter = createCrashArtifactWriter({ - runnerName: platform.name, - platformId: platform.platformId, - }); - const appMonitor = platformInstance.createAppMonitor({ crashArtifactWriter }); const appLaunchOptions = (platform.config as { appLaunchOptions?: AppLaunchOptions }).appLaunchOptions; - const crashMonitor = createCrashMonitor({ appMonitor, platformRunner: platformInstance }); + let currentAppSession: AppSession | null = null; + const crashMonitor = createCrashMonitor(); + + const disposeCurrentAppSession = async () => { + const session = currentAppSession; + currentAppSession = null; + crashMonitor.setAppSession(null); + if (session) { + await session.dispose(); + } + }; + + const restartAppSession = async (): Promise => { + await crashMonitor.stop(); + await disposeCurrentAppSession(); + const session = await platformInstance.createAppSession(appLaunchOptions); + currentAppSession = session; + crashMonitor.setAppSession(session); + await crashMonitor.start(); + return session; + }; // Pre-build the options that are constant across all app-ready calls; // only testFilePath varies per call. const appReadyBaseOptions: AppReadyOptions = { metroInstance, bridge, - platformInstance, platformId: platform.platformId, bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000, readyTimeout: runtimeConfig.bridgeTimeout, maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2, crashMonitor, - appLaunchOptions, + restartAppSession, }; // --- Event listeners --- @@ -508,6 +520,9 @@ export const createHarnessSession = async ( const runId = getCurrentRunId(); if (!runId) return; hooks.schedule(() => pluginManager.callHook('runtime:disconnected', { runId, reason: 'bridge-disconnected' })); + if (runtimeConfig.detectNativeCrashes !== false) { + crashMonitor.handleBridgeDisconnect(); + } }; bridge.on('connected', onConnected); @@ -561,7 +576,8 @@ export const createHarnessSession = async ( const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios; if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) { try { - await platformInstance.stopApp(); + await crashMonitor.stop(); + await disposeCurrentAppSession(); const lcovPath = await platformInstance.collectNativeCoverage({ pods: nativeCoverageConfig.pods, outputDir: projectRoot, @@ -578,6 +594,7 @@ export const createHarnessSession = async ( try { await Promise.all([ crashMonitor.dispose(), + disposeCurrentAppSession(), bridge.dispose(), platformInstance.dispose(), metroInstance.dispose(), @@ -614,8 +631,6 @@ export const createHarnessSession = async ( try { await pluginManager.callHook('harness:before-creation', { appLaunchOptions }); await hooks.drain(); - await appMonitor.start(); - sessionLogger.debug('app monitor started'); await pluginManager.callHook('harness:before-run', { appLaunchOptions }); await hooks.drain(); } catch (error) { @@ -634,9 +649,12 @@ export const createHarnessSession = async ( await hooks.drain(); sessionLogger.debug('ensuring app is ready for %s', testFilePath); - if (crashMonitor.isAlive() && bridge.connection !== null && await platformInstance.isAppRunning()) { - sessionLogger.debug('reusing existing ready app for %s', testFilePath); - return; + if (crashMonitor.isAlive() && bridge.connection !== null && currentAppSession) { + const state = await currentAppSession.getState(); + if (state.status === 'running') { + sessionLogger.debug('reusing existing ready app for %s', testFilePath); + return; + } } crashMonitor.reset(); @@ -655,17 +673,16 @@ export const createHarnessSession = async ( testFilePath ? 'stop-and-ensure-ready' : 'direct-restart', ); - if (testFilePath) { - await platformInstance.stopApp(); - } else { - await platformInstance.restartApp(appLaunchOptions); - } - - crashMonitor.reset(); - await crashMonitor.start(); + await disposeCurrentAppSession(); if (testFilePath) { await ensureAppReady(testFilePath); + } else { + const session = await platformInstance.createAppSession(appLaunchOptions); + currentAppSession = session; + crashMonitor.setAppSession(session); + crashMonitor.reset(); + await crashMonitor.start(); } await hooks.drain(); @@ -681,7 +698,7 @@ export const createHarnessSession = async ( if (!conn) throw new Error('No active app connection'); sessionLogger.debug('running test file on client: %s', testPath); - if (!runtimeConfig.detectNativeCrashes) { + if (runtimeConfig.detectNativeCrashes === false) { const result = await conn.runTests(testPath, { ...options, runner: platform.runner }); await hooks.drain(); return result; diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 3ead8cd6..5985753f 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -502,14 +502,17 @@ describe('Android platform instance', () => { init, ); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppRunning').mockResolvedValue(false); + const listener = vi.fn(); - const appMonitor = instance.createAppMonitor(); + const appSession = await instance.createAppSession(); - await expect(appMonitor.start()).resolves.toBeUndefined(); - await expect(appMonitor.stop()).resolves.toBeUndefined(); - await expect(appMonitor.dispose()).resolves.toBeUndefined(); - expect(appMonitor.addListener(listener)).toBeUndefined(); - expect(appMonitor.removeListener(listener)).toBeUndefined(); + expect(appSession.getLogs()).toEqual([]); + expect(appSession.addListener(listener)).toBeUndefined(); + expect(appSession.removeListener(listener)).toBeUndefined(); + await expect(appSession.dispose()).resolves.toBeUndefined(); }); it('returns a noop physical device app monitor when native crash detection is disabled', async () => { @@ -556,14 +559,17 @@ describe('Android platform instance', () => { harnessConfigWithoutNativeCrashDetection, ); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppRunning').mockResolvedValue(false); + const listener = vi.fn(); - const appMonitor = instance.createAppMonitor(); + const appSession = await instance.createAppSession(); - await expect(appMonitor.start()).resolves.toBeUndefined(); - await expect(appMonitor.stop()).resolves.toBeUndefined(); - await expect(appMonitor.dispose()).resolves.toBeUndefined(); - expect(appMonitor.addListener(listener)).toBeUndefined(); - expect(appMonitor.removeListener(listener)).toBeUndefined(); + expect(appSession.getLogs()).toEqual([]); + expect(appSession.addListener(listener)).toBeUndefined(); + expect(appSession.removeListener(listener)).toBeUndefined(); + await expect(appSession.dispose()).resolves.toBeUndefined(); }); it('grants permissions when permissions are enabled for emulator', async () => { diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index d28eb177..2b98bf8a 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -1,8 +1,10 @@ import { AppNotInstalledError, - CreateAppMonitorOptions, DeviceNotFoundError, + createAppSessionEmitter, type HarnessPlatformInitOptions, + type AppSession, + type AppSessionState, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; @@ -24,7 +26,6 @@ import { clearHarnessDebugHttpHost, } from './shared-prefs.js'; import { getDeviceName } from './utils.js'; -import { createAndroidAppMonitor } from './app-monitor.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; import { ensureAndroidEmulatorAvailable, @@ -33,17 +34,70 @@ import { } from './environment.js'; import { isInteractive } from '@react-native-harness/tools'; import fs from 'node:fs'; -import type { AppMonitor } from '@react-native-harness/platforms'; const androidInstanceLogger = logger.child('android-instance'); +const APP_EXIT_POLL_INTERVAL_MS = 1000; -const createNoopAppMonitor = (): AppMonitor => ({ - start: async () => undefined, - stop: async () => undefined, - dispose: async () => undefined, - addListener: () => undefined, - removeListener: () => undefined, -}); +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const createAndroidAppSession = async ({ + startApp, + stopApp, + isAppRunning, +}: { + startApp: () => Promise; + stopApp: () => Promise; + isAppRunning: () => Promise; +}): Promise => { + const emitter = createAppSessionEmitter(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + + await startApp(); + + const pollTask = (async () => { + while (!stopPolling) { + try { + if (!(await isAppRunning())) { + if (!disposed && state.status === 'running') { + state = { + status: 'exited', + occurredAt: Date.now(), + reason: 'process-gone', + }; + emitter.emit({ type: 'app_exited' }); + } + return; + } + } catch (error) { + androidInstanceLogger.debug('Android app session poll failed', error); + } + + await sleep(APP_EXIT_POLL_INTERVAL_MS); + } + })(); + + return { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + await stopApp(); + await pollTask; + }, + getState: async () => state, + getLogs: () => [], + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; +}; const getHarnessAppPath = (): string => { const appPath = process.env.HARNESS_APP_PATH; @@ -193,7 +247,6 @@ export const getAndroidEmulatorPlatformInstance = async ( init: HarnessPlatformInitOptions, ): Promise => { assertAndroidDeviceEmulator(config.device); - const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; const permissionsEnabled = harnessConfig.permissions ?? false; const emulatorConfig = config.device; const emulatorName = emulatorConfig.name; @@ -275,34 +328,30 @@ export const getAndroidEmulatorPlatformInstance = async ( await adb.installApp(adbId, installPath); } - const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + await configureAndroidRuntime(adbId, config, harnessConfig); if (permissionsEnabled) { await adb.grantPermissions(adbId, config.bundleId); } return { - startApp: async (options) => { - await adb.startApp( - adbId, - config.bundleId, - config.activityName, - (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - restartApp: async (options) => { + createAppSession: async (options) => { await adb.stopApp(adbId, config.bundleId); - await adb.startApp( - adbId, - config.bundleId, - config.activityName, + const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - stopApp: async () => { - await adb.stopApp(adbId, config.bundleId); + config.appLaunchOptions; + + return await createAndroidAppSession({ + startApp: () => + adb.startApp( + adbId, + config.bundleId, + config.activityName, + launchOptions, + ), + stopApp: () => adb.stopApp(adbId, config.bundleId), + isAppRunning: () => adb.isAppRunning(adbId, config.bundleId), + }); }, dispose: async () => { await adb.stopApp(adbId, config.bundleId); @@ -314,21 +363,6 @@ export const getAndroidEmulatorPlatformInstance = async ( await adb.stopEmulator(adbId); } }, - isAppRunning: async () => { - return await adb.isAppRunning(adbId, config.bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => { - if (!detectNativeCrashes) { - return createNoopAppMonitor(); - } - - return createAndroidAppMonitor({ - adbId, - bundleId: config.bundleId, - appUid, - crashArtifactWriter: options?.crashArtifactWriter, - }); - }, }; }; @@ -337,7 +371,6 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( harnessConfig: HarnessConfig, ): Promise => { assertAndroidDevicePhysical(config.device); - const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; const permissionsEnabled = harnessConfig.permissions ?? false; const adbId = await getAdbId(config.device); @@ -355,54 +388,35 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( ); } - const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + await configureAndroidRuntime(adbId, config, harnessConfig); if (permissionsEnabled) { await adb.grantPermissions(adbId, config.bundleId); } return { - startApp: async (options) => { - await adb.startApp( - adbId, - config.bundleId, - config.activityName, - (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - restartApp: async (options) => { + createAppSession: async (options) => { await adb.stopApp(adbId, config.bundleId); - await adb.startApp( - adbId, - config.bundleId, - config.activityName, + const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - stopApp: async () => { - await adb.stopApp(adbId, config.bundleId); + config.appLaunchOptions; + + return await createAndroidAppSession({ + startApp: () => + adb.startApp( + adbId, + config.bundleId, + config.activityName, + launchOptions, + ), + stopApp: () => adb.stopApp(adbId, config.bundleId), + isAppRunning: () => adb.isAppRunning(adbId, config.bundleId), + }); }, dispose: async () => { await adb.stopApp(adbId, config.bundleId); await clearHarnessDebugHttpHost(adbId, config.bundleId); await adb.setHideErrorDialogs(adbId, false); }, - isAppRunning: async () => { - return await adb.isAppRunning(adbId, config.bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => { - if (!detectNativeCrashes) { - return createNoopAppMonitor(); - } - - return createAndroidAppMonitor({ - adbId, - bundleId: config.bundleId, - appUid, - crashArtifactWriter: options?.crashArtifactWriter, - }); - }, }; }; diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts index eac3fc88..8515eab7 100644 --- a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts +++ b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts @@ -48,7 +48,6 @@ describe('iOS XCTest agent runner integration', () => { vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( undefined, ); - vi.spyOn(simctl, 'startApp').mockResolvedValue(undefined); vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( undefined, @@ -70,7 +69,6 @@ describe('iOS XCTest agent runner integration', () => { }, ); - await instance.startApp(); await instance.dispose(); expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ @@ -104,7 +102,6 @@ describe('iOS XCTest agent runner integration', () => { }, }); vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(devicectl, 'startApp').mockResolvedValue(undefined); vi.spyOn(devicectl, 'stopApp').mockResolvedValue(undefined); const instance = await getApplePhysicalDevicePlatformInstance( @@ -113,13 +110,15 @@ describe('iOS XCTest agent runner integration', () => { device: { type: 'physical', name: 'My iPhone', + codeSign: { + teamId: 'TEAMID1234', + }, }, bundleId: 'com.harnessplayground', }, harnessConfigWithPermissionsEnabled, ); - await instance.restartApp(); await instance.dispose(); expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ @@ -132,6 +131,9 @@ describe('iOS XCTest agent runner integration', () => { target: { kind: 'device', id: 'device-udid', + codeSign: { + teamId: 'TEAMID1234', + }, }, }); expect(mocks.prepare).not.toHaveBeenCalled(); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 7dd0864f..4c7804a1 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -188,7 +188,7 @@ describe('iOS platform instance dependency validation', () => { ).resolves.toBeDefined(); }); - it('returns a noop simulator app monitor when native crash detection is disabled', async () => { + it('exposes simulator app sessions when native crash detection is disabled', async () => { vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); @@ -210,14 +210,7 @@ describe('iOS platform instance dependency validation', () => { init, ); - const listener = vi.fn(); - const appMonitor = instance.createAppMonitor(); - - await expect(appMonitor.start()).resolves.toBeUndefined(); - await expect(appMonitor.stop()).resolves.toBeUndefined(); - await expect(appMonitor.dispose()).resolves.toBeUndefined(); - expect(appMonitor.addListener(listener)).toBeUndefined(); - expect(appMonitor.removeListener(listener)).toBeUndefined(); + expect(instance.createAppSession).toEqual(expect.any(Function)); }); it('reuses a booted simulator and does not shut it down on dispose', async () => { diff --git a/packages/platform-ios/src/app-session.ts b/packages/platform-ios/src/app-session.ts new file mode 100644 index 00000000..94dfc1bb --- /dev/null +++ b/packages/platform-ios/src/app-session.ts @@ -0,0 +1,133 @@ +import { + createAppSessionEmitter, + createBoundedLogBuffer, + type AppSession, + type AppSessionState, + type AppleAppLaunchOptions, +} from '@react-native-harness/platforms'; +import { logger, type Subprocess } from '@react-native-harness/tools'; + +const iosAppSessionLogger = logger.child('ios-app-session'); +const APP_EXIT_POLL_INTERVAL_MS = 1000; +const LAUNCH_FAILURE_SETTLE_MS = 100; + +type CreateIosAppSessionOptions = { + launch: () => Subprocess; + stopApp: () => Promise; + isAppRunning: () => Promise; +}; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const createIosAppSession = async ({ + launch, + stopApp, + isAppRunning, +}: CreateIosAppSessionOptions): Promise => { + const emitter = createAppSessionEmitter(); + const logBuffer = createBoundedLogBuffer(); + const launchProcess = launch(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + + const setExited = (reason: 'observed-exit' | 'process-gone') => { + if (disposed || state.status !== 'running') { + return; + } + + state = { status: 'exited', occurredAt: Date.now(), reason }; + emitter.emit({ type: 'app_exited' }); + }; + + const logTask = (async () => { + try { + for await (const line of launchProcess) { + if (!disposed) { + logBuffer.push(String(line)); + } + } + } catch (error) { + if (!disposed) { + iosAppSessionLogger.debug('iOS app launch log stream stopped', error); + } + } + })(); + + const exitTask = (async () => { + try { + await launchProcess; + if (!disposed && !(await isAppRunning())) { + setExited('observed-exit'); + } + } catch (error) { + if (!disposed) { + logBuffer.push(error instanceof Error ? error.message : String(error)); + setExited('observed-exit'); + } + } + })(); + + const pollTask = (async () => { + while (!stopPolling) { + try { + if (!(await isAppRunning())) { + setExited('process-gone'); + return; + } + } catch (error) { + iosAppSessionLogger.debug('iOS app session poll failed', error); + } + + await sleep(APP_EXIT_POLL_INTERVAL_MS); + } + })(); + + const launchSettled = await Promise.race([ + launchProcess.then( + () => 'settled' as const, + () => 'settled' as const, + ), + sleep(LAUNCH_FAILURE_SETTLE_MS).then(() => 'running' as const), + ]); + + if (launchSettled === 'settled' && !(await isAppRunning())) { + disposed = true; + stopPolling = true; + emitter.clear(); + await Promise.allSettled([logTask, exitTask, pollTask]); + await launchProcess; + throw new Error('The iOS app launch finished before the app was running.'); + } + + return { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + + try { + (await launchProcess.nodeChildProcess).kill(); + } catch { + // Ignore termination failures for already-ended launch streams. + } + + await stopApp(); + await Promise.allSettled([logTask, exitTask, pollTask]); + }, + getState: async () => state, + getLogs: () => logBuffer.getLogs(), + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; +}; + +export type CreateIosPlatformSessionOptions = { + options?: AppleAppLaunchOptions; +}; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 36c62cd1..ec3a44b1 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -1,8 +1,6 @@ import { - AppMonitor, AppNotInstalledError, type CollectNativeCoverageOptions, - CreateAppMonitorOptions, DeviceNotFoundError, type HarnessPlatformInitOptions, HarnessPlatformRunner, @@ -19,16 +17,13 @@ import { import * as simctl from './xcrun/simctl.js'; import * as devicectl from './xcrun/devicectl.js'; import { getDeviceName } from './utils.js'; -import { - createIosDeviceAppMonitor, - createIosSimulatorAppMonitor, -} from './app-monitor.js'; import { HarnessAppPathError } from './errors.js'; import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; import { createXCTestAgentController } from './xctest-agent.js'; import { createPermissionPromptAutoAcceptCapability } from './xctest-agent-capabilities.js'; import { collectNativeCoverage, cleanProfrawDir } from './coverage-collector.js'; +import { createIosAppSession } from './app-session.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -46,21 +41,12 @@ const getHarnessAppPath = (): string => { return appPath; }; -const createNoopAppMonitor = (): AppMonitor => ({ - start: async () => undefined, - stop: async () => undefined, - dispose: async () => undefined, - addListener: () => undefined, - removeListener: () => undefined, -}); - export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, harnessConfig: HarnessConfig, init: HarnessPlatformInitOptions, ): Promise => { assertAppleDeviceSimulator(config.device); - const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; const permissionsEnabled = harnessConfig.permissions ?? false; if (harnessConfig.coverage?.native?.ios?.pods?.length) { @@ -155,25 +141,18 @@ export const getAppleSimulatorPlatformInstance = async ( } return { - startApp: async (options) => { - await simctl.startApp( - udid, - config.bundleId, - (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - restartApp: async (options) => { + createAppSession: async (options) => { await simctl.stopApp(udid, config.bundleId); - await simctl.startApp( - udid, - config.bundleId, + const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - stopApp: async () => { - await simctl.stopApp(udid, config.bundleId); + config.appLaunchOptions; + + return await createIosAppSession({ + launch: () => + simctl.launchAppProcess(udid, config.bundleId, launchOptions), + stopApp: () => simctl.stopApp(udid, config.bundleId), + isAppRunning: () => simctl.isAppRunning(udid, config.bundleId), + }); }, dispose: async () => { await xctestAgent?.dispose(); @@ -185,20 +164,6 @@ export const getAppleSimulatorPlatformInstance = async ( await simctl.shutdownSimulator(udid); } }, - isAppRunning: async () => { - return await simctl.isAppRunning(udid, config.bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => { - if (!detectNativeCrashes) { - return createNoopAppMonitor(); - } - - return createIosSimulatorAppMonitor({ - udid, - bundleId: config.bundleId, - crashArtifactWriter: options?.crashArtifactWriter, - }); - }, collectNativeCoverage: async (options: CollectNativeCoverageOptions) => { return await collectNativeCoverage({ udid, @@ -215,7 +180,6 @@ export const getApplePhysicalDevicePlatformInstance = async ( harnessConfig: HarnessConfig, ): Promise => { assertAppleDevicePhysical(config.device); - const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; const permissionsEnabled = harnessConfig.permissions ?? false; if (harnessConfig.metroPort !== DEFAULT_METRO_PORT) { @@ -270,43 +234,22 @@ export const getApplePhysicalDevicePlatformInstance = async ( } return { - startApp: async (options) => { - await devicectl.startApp( - deviceId, - config.bundleId, - (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - restartApp: async (options) => { + createAppSession: async (options) => { await devicectl.stopApp(deviceId, config.bundleId); - await devicectl.startApp( - deviceId, - config.bundleId, + const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions, - ); - }, - stopApp: async () => { - await devicectl.stopApp(deviceId, config.bundleId); + config.appLaunchOptions; + + return await createIosAppSession({ + launch: () => + devicectl.launchAppProcess(deviceId, config.bundleId, launchOptions), + stopApp: () => devicectl.stopApp(deviceId, config.bundleId), + isAppRunning: () => devicectl.isAppRunning(deviceId, config.bundleId), + }); }, dispose: async () => { await xctestAgent?.dispose(); await devicectl.stopApp(deviceId, config.bundleId); }, - isAppRunning: async () => { - return await devicectl.isAppRunning(deviceId, config.bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => { - if (!detectNativeCrashes) { - return createNoopAppMonitor(); - } - - return createIosDeviceAppMonitor({ - deviceId, - bundleId: config.bundleId, - crashArtifactWriter: options?.crashArtifactWriter, - }); - }, }; }; diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index 521951e5..de938e38 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -1,5 +1,5 @@ import { type AppleAppLaunchOptions } from '@react-native-harness/platforms'; -import { spawn } from '@react-native-harness/tools'; +import { spawn, type Subprocess } from '@react-native-harness/tools'; import fs from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -209,6 +209,20 @@ export const startApp = async ( ); }; +export const launchAppProcess = ( + identifier: string, + bundleId: string, + options?: AppleAppLaunchOptions +): Subprocess => + spawn( + 'xcrun', + ['devicectl', 'device', ...getDeviceCtlLaunchArgs(identifier, bundleId, options)], + { + stdout: 'pipe', + stderr: 'pipe', + } + ); + export const getDeviceCtlLaunchArgs = ( identifier: string, bundleId: string, diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index f5fdaa37..3990e795 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -290,6 +290,25 @@ export const startApp = async ( }); }; +export const launchAppProcess = ( + udid: string, + bundleId: string, + options?: AppleAppLaunchOptions, +): Subprocess => { + const environment = getSimctlChildEnvironment(options); + const argumentsList = options?.arguments ?? []; + + return spawn( + 'xcrun', + ['simctl', 'launch', '--console', udid, bundleId, ...argumentsList], + { + env: environment, + stdout: 'pipe', + stderr: 'pipe', + }, + ); +}; + export const stopApp = async ( udid: string, bundleId: string, diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index 7f86eef8..4b637e30 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -1,71 +1,19 @@ import { - type AppMonitor, - type AppMonitorEvent, + createAppSessionEmitter, + type AppSession, + type AppSessionState, DeviceNotFoundError, AppNotInstalledError, - type CreateAppMonitorOptions, type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; -import { getEmitter } from '@react-native-harness/tools'; import { VegaPlatformConfigSchema, type VegaPlatformConfig } from './config.js'; import * as kepler from './kepler.js'; -const createPollingAppMonitor = ({ - interval, - isAppRunning, -}: { - interval: number; - isAppRunning: () => Promise; -}): AppMonitor => { - const emitter = getEmitter(); - let timer: NodeJS.Timeout | null = null; - let started = false; - let wasRunning = false; +const APP_EXIT_POLL_INTERVAL_MS = 1000; - const start = async () => { - if (started) { - return; - } - - started = true; - wasRunning = await isAppRunning(); - - timer = setInterval(async () => { - const running = await isAppRunning(); - - if (running && !wasRunning) { - emitter.emit({ type: 'app_started', source: 'polling' }); - } else if (!running && wasRunning) { - emitter.emit({ type: 'app_exited', source: 'polling' }); - } - - wasRunning = running; - }, interval); - }; - - const stop = async () => { - started = false; - - if (timer) { - clearInterval(timer); - timer = null; - } - }; - - const dispose = async () => { - await stop(); - emitter.clearAllListeners(); - }; - - return { - start, - stop, - dispose, - addListener: emitter.addListener, - removeListener: emitter.removeListener, - }; -}; +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); const getVegaRunner = async ( config: VegaPlatformConfig, @@ -88,29 +36,51 @@ const getVegaRunner = async ( } return { - startApp: async () => { - await kepler.startApp(deviceId, bundleId); - }, - restartApp: async () => { + createAppSession: async (): Promise => { await kepler.stopApp(deviceId, bundleId); await kepler.startApp(deviceId, bundleId); - }, - stopApp: async () => { - await kepler.stopApp(deviceId, bundleId); + + const emitter = createAppSessionEmitter(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + + const pollTask = (async () => { + while (!stopPolling) { + if (!(await kepler.isAppRunning(deviceId, bundleId))) { + if (!disposed && state.status === 'running') { + state = { status: 'exited', occurredAt: Date.now(), reason: 'process-gone' }; + emitter.emit({ type: 'app_exited' }); + } + return; + } + + await sleep(APP_EXIT_POLL_INTERVAL_MS); + } + })(); + + return { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + await kepler.stopApp(deviceId, bundleId); + await pollTask; + }, + getState: async () => state, + getLogs: () => [], + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; }, dispose: async () => { await kepler.stopApp(deviceId, bundleId); }, - isAppRunning: async () => { - return await kepler.isAppRunning(deviceId, bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => { - void options; - return createPollingAppMonitor({ - interval: 250, - isAppRunning: () => kepler.isAppRunning(deviceId, bundleId), - }); - }, }; }; diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index bcf74528..84ee7ed5 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -17,7 +17,6 @@ }, "dependencies": { "@react-native-harness/platforms": "workspace:*", - "@react-native-harness/tools": "workspace:*", "playwright": "^1.50.0", "zod": "^3.25.67", "tslib": "^2.3.0" diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index d67cf97b..affac932 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -1,70 +1,13 @@ import { - type AppMonitor, - type AppMonitorEvent, - type CreateAppMonitorOptions, + createAppSessionEmitter, + type AppSession, + type AppSessionState, type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; -import { getEmitter } from '@react-native-harness/tools'; import { WebPlatformConfigSchema, type WebPlatformConfig } from './config.js'; -const createPollingAppMonitor = ({ - interval, - isAppRunning, -}: { - interval: number; - isAppRunning: () => Promise; -}): AppMonitor => { - const emitter = getEmitter(); - let timer: NodeJS.Timeout | null = null; - let started = false; - let wasRunning = false; - - const start = async () => { - if (started) { - return; - } - - started = true; - wasRunning = await isAppRunning(); - - timer = setInterval(async () => { - const running = await isAppRunning(); - - if (running && !wasRunning) { - emitter.emit({ type: 'app_started', source: 'polling' }); - } else if (!running && wasRunning) { - emitter.emit({ type: 'app_exited', source: 'polling' }); - } - - wasRunning = running; - }, interval); - }; - - const stop = async () => { - started = false; - - if (timer) { - clearInterval(timer); - timer = null; - } - }; - - const dispose = async () => { - await stop(); - emitter.clearAllListeners(); - }; - - return { - start, - stop, - dispose, - addListener: emitter.addListener, - removeListener: emitter.removeListener, - }; -}; - const getWebRunner = async ( config: WebPlatformConfig, init?: HarnessPlatformInitOptions @@ -183,24 +126,43 @@ const getWebRunner = async ( }; return { - startApp: async () => { - if (!browser) { - await launchBrowser(); - } - }, - restartApp: async () => { - if (page) { - await page.reload(); - } else { - await launchBrowser(); - } - }, - stopApp: async () => { + createAppSession: async (): Promise => { if (browser) { await browser.close(); browser = null; page = null; } + await launchBrowser(); + + const emitter = createAppSessionEmitter(); + let state: AppSessionState = { status: 'running' }; + + page?.on('close', () => { + if (state.status === 'running') { + state = { status: 'exited', occurredAt: Date.now(), reason: 'observed-exit' }; + emitter.emit({ type: 'app_exited' }); + } + }); + + return { + dispose: async () => { + if (state.status === 'disposed') { + return; + } + + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + if (browser) { + await browser.close(); + browser = null; + page = null; + } + }, + getState: async () => state, + getLogs: () => [], + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; }, dispose: async () => { if (browser) { @@ -209,17 +171,6 @@ const getWebRunner = async ( page = null; } }, - isAppRunning: async () => { - return browser !== null && page !== null && !page.isClosed(); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => { - void options; - return createPollingAppMonitor({ - interval: 250, - isAppRunning: async () => - browser !== null && page !== null && !page.isClosed(), - }); - }, }; }; diff --git a/packages/platform-web/tsconfig.json b/packages/platform-web/tsconfig.json index 56b5cd95..9f9888e0 100644 --- a/packages/platform-web/tsconfig.json +++ b/packages/platform-web/tsconfig.json @@ -3,9 +3,6 @@ "files": [], "include": [], "references": [ - { - "path": "../tools" - }, { "path": "../platforms" }, diff --git a/packages/platform-web/tsconfig.lib.json b/packages/platform-web/tsconfig.lib.json index 362f35d8..595d0aa3 100644 --- a/packages/platform-web/tsconfig.lib.json +++ b/packages/platform-web/tsconfig.lib.json @@ -12,9 +12,6 @@ }, "include": ["src/**/*.ts"], "references": [ - { - "path": "../tools/tsconfig.lib.json" - }, { "path": "../platforms/tsconfig.lib.json" } diff --git a/packages/platforms/README.md b/packages/platforms/README.md index 30c72890..c68256d9 100644 --- a/packages/platforms/README.md +++ b/packages/platforms/README.md @@ -22,25 +22,22 @@ yarn add @react-native-harness/platforms This package provides the core abstractions for creating platform implementations. It's typically used by platform-specific packages rather than directly by end users. ```typescript -import { createHarnessPlatform } from '@react-native-harness/platforms'; +import type { HarnessPlatformRunner } from '@react-native-harness/platforms'; -const platform = createHarnessPlatform({ - name: 'my-platform', - getInstance: async () => ({ - startApp: async () => { - /* implementation */ - }, - restartApp: async () => { - /* implementation */ - }, - stopApp: async () => { - /* implementation */ - }, +const runner: HarnessPlatformRunner = { + createAppSession: async () => ({ dispose: async () => { - /* implementation */ + /* kill the launched app */ }, + getState: async () => ({ status: 'running' }), + getLogs: () => [], + addListener: () => undefined, + removeListener: () => undefined, }), -}); + dispose: async () => { + /* clean up platform resources */ + }, +}; ``` ## API @@ -65,17 +62,26 @@ Core platform interface. - `name` - Platform name - `getInstance()` - Returns a platform instance -### `HarnessPlatformInstance` +### `HarnessPlatformRunner` -Platform instance interface with lifecycle methods. +Platform runner interface with lifecycle methods. **Methods:** -- `startApp()` - Starts the application -- `restartApp()` - Restarts the application -- `stopApp()` - Stops the application +- `createAppSession()` - Launches the application and returns an `AppSession` - `dispose()` - Cleans up resources +### `AppSession` + +One launched application session. Sessions are terminal; restarting means disposing the current session and creating a new one. + +**Methods:** + +- `dispose()` - Intentionally kills the launched app +- `getState()` - Returns the cached session state +- `getLogs()` - Returns bounded logs for this launched app session +- `addListener()` / `removeListener()` - Subscribes to minimal session lifecycle events + ### Error Classes #### `AppNotInstalledError` diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 043a4f30..1f7e36fa 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -5,6 +5,11 @@ export type { AndroidAppLaunchOptions, AppleAppLaunchOptions, AppCrashDetails, + AppSession, + AppSessionEvent, + AppSessionListener, + AppSessionLog, + AppSessionState, AppMonitor, AppMonitorEvent, AppMonitorListener, @@ -21,6 +26,11 @@ export type { VegaAppLaunchOptions, WebAppLaunchOptions, } from './types.js'; +export { + createAppSessionEmitter, + createBoundedLogBuffer, + createNoopAppSession, +} from './session.js'; export { AppNotInstalledError, DeviceNotFoundError, diff --git a/packages/platforms/src/session.ts b/packages/platforms/src/session.ts new file mode 100644 index 00000000..48143237 --- /dev/null +++ b/packages/platforms/src/session.ts @@ -0,0 +1,60 @@ +import type { + AppSession, + AppSessionEvent, + AppSessionLog, + AppSessionListener, + AppSessionState, +} from './types.js'; + +export const createBoundedLogBuffer = (limit = 500) => { + let logs: AppSessionLog[] = []; + + return { + push: (line: string, occurredAt = Date.now()) => { + logs = [...logs, { line, occurredAt }].slice(-limit); + }, + getLogs: () => [...logs], + clear: () => { + logs = []; + }, + }; +}; + +export const createNoopAppSession = (): AppSession => { + let state: AppSessionState = { status: 'running' }; + const listeners = new Set(); + + return { + dispose: async () => { + state = { status: 'disposed', occurredAt: Date.now() }; + listeners.clear(); + }, + getState: async () => state, + getLogs: () => [], + addListener: (listener) => { + listeners.add(listener); + }, + removeListener: (listener) => { + listeners.delete(listener); + }, + }; +}; + +export const createAppSessionEmitter = () => { + const listeners = new Set(); + + return { + emit: (event: AppSessionEvent) => { + for (const listener of listeners) listener(event); + }, + addListener: (listener: AppSessionListener) => { + listeners.add(listener); + }, + removeListener: (listener: AppSessionListener) => { + listeners.delete(listener); + }, + clear: () => { + listeners.clear(); + }, + }; +}; diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index f4d16819..fb5c294b 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -79,6 +79,39 @@ export type AppMonitor = { removeListener: (listener: AppMonitorListener) => void; }; +export type AppSessionLog = { + line: string; + occurredAt: number; +}; + +export type AppSessionEvent = { type: 'app_exited' }; + +export type AppSessionListener = (event: AppSessionEvent) => void; + +export type AppSessionState = + | { + status: 'running'; + pid?: number; + } + | { + status: 'exited'; + occurredAt: number; + pid?: number; + reason?: 'observed-exit' | 'process-gone'; + } + | { + status: 'disposed'; + occurredAt: number; + }; + +export type AppSession = { + dispose: () => Promise; + getState: () => Promise; + getLogs: () => AppSessionLog[]; + addListener: (listener: AppSessionListener) => void; + removeListener: (listener: AppSessionListener) => void; +}; + export type AndroidAppLaunchOptions = { extras?: Record; }; @@ -104,15 +137,8 @@ export type CollectNativeCoverageOptions = { }; export type HarnessPlatformRunner = { - startApp: (options?: AppLaunchOptions) => Promise; - restartApp: (options?: AppLaunchOptions) => Promise; - stopApp: () => Promise; + createAppSession: (options?: AppLaunchOptions) => Promise; dispose: () => Promise; - isAppRunning: () => Promise; - createAppMonitor: (options?: CreateAppMonitorOptions) => AppMonitor; - getCrashDetails?: ( - options: CrashDetailsLookupOptions, - ) => Promise; collectNativeCoverage?: ( options: CollectNativeCoverageOptions ) => Promise; From cda30ad0ca1204a30c62751f8a1c64063e1704bf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 10:58:14 +0200 Subject: [PATCH 02/17] fix(jest): harden crash detection races --- CRASH_PLAN.md | 356 ++++++++++++++++++ .../jest/src/__tests__/crash-monitor.test.ts | 2 +- .../src/__tests__/harness-session.test.ts | 68 ++++ packages/jest/src/crash-monitor.ts | 2 +- packages/jest/src/harness-session.ts | 69 +++- 5 files changed, 491 insertions(+), 6 deletions(-) create mode 100644 CRASH_PLAN.md create mode 100644 packages/jest/src/__tests__/harness-session.test.ts diff --git a/CRASH_PLAN.md b/CRASH_PLAN.md new file mode 100644 index 00000000..7dc2f119 --- /dev/null +++ b/CRASH_PLAN.md @@ -0,0 +1,356 @@ +# Crash Detection Refactor Plan + +## Goal + +Refactor app lifecycle and crash detection so Harness can treat bridge disconnects as the earliest crash signal, confirm whether the app really died, and surface either a confirmed native crash or a separate runtime-disconnect failure with the best available evidence. + +## Decisions + +- Bridge disconnect should start an internal crash-investigation workflow. +- If bridge disconnect is followed by `getState().status === 'exited'`, it should be treated as a crash even if no explicit crash log lines are found. +- `AppSession` should store a bounded log buffer for the whole run, and that buffer is assumed to include crash output too. +- The investigation should read the session log buffer and attach matching crash indicators when they exist. +- The final classification wait should be controlled by a constant so it can be tuned later. +- Intentional restart and teardown must not report crashes. +- `dispose()` should remain a killing action. +- `createAppSession()` should resolve only after launch log capture and exit observation are attached and early launch failures have already surfaced. +- If `createAppSession()` resolves successfully, the app should already be considered started; no separate `app_started` event is needed. +- New TypeScript shapes should use `type` aliases instead of interfaces. +- New object implementations should use factory functions instead of classes, except for errors. +- Error implementations should remain classes. +- Android should satisfy the new contracts with a no-op evidence implementation for now; full Android crash evidence is intentionally deferred. +- iOS v1 crash evidence should come only from session logs, not `.ips` or `.crash` artifact collection. + +## Architecture Direction + +- Replace the current `startApp` / `restartApp` / `stopApp` runner contract with `createAppSession(options?)`. +- One launch creates one `AppSession`. +- `AppSession` is terminal. When it dies, restarting means creating a new session. +- `AppSession` should absorb the current app-launch and app-monitor responsibilities. +- `crash-monitor.ts` should remain the orchestrator that combines app-session exit signals with bridge disconnect signals. +- Bridge concerns should stay out of the platform session implementation. +- Session logs should come directly from the launch stream for that app (`simctl launch ...` / `devicectl ... launch ...`), so they are already scoped to the launched app. + +## AppSession Contract + +`AppSession` should own: + +- the launched app process or console-attached launch handle +- a bounded log ring buffer for the whole run +- app exit notification +- disposal + +Proposed shape: + +```ts +type AppSessionLog = { + line: string; + occurredAt: number; +}; + +type AppSession = { + dispose: () => Promise; + getState: () => Promise; + getLogs: () => AppSessionLog[]; + addListener: (listener: AppSessionListener) => void; + removeListener: (listener: AppSessionListener) => void; +}; +``` + +Rules: + +- `dispose()` kills the app. +- After `dispose()`, the session must ignore its own late exit or disconnect signals. +- A session should expose events only after launch log capture and exit observation are ready. +- The session log buffer is the authoritative near-real-time evidence channel for crash text. +- `getLogs()` returns only logs produced by that launched app session. +- `getState()` returns the cached app-session state only. It must not perform platform liveness checks. +- Session-owned exit observation is responsible for updating cached state; when the session observes that the process is gone, the cached state should become `status: 'exited'`. + +Suggested state shape: + +```ts +type AppSessionState = + | { + status: 'running'; + pid?: number; + } + | { + status: 'exited'; + occurredAt: number; + pid?: number; + reason?: 'observed-exit' | 'process-gone'; + } + | { + status: 'disposed'; + occurredAt: number; + }; +``` + +`HarnessPlatformRunner` should move from app commands to session creation: + +```ts +type HarnessPlatformRunner = { + createAppSession: (options?: AppLaunchOptions) => Promise; + dispose: () => Promise; + collectNativeCoverage?: ( + options: CollectNativeCoverageOptions, + ) => Promise; +}; +``` + +The existing `startApp()`, `restartApp()`, `stopApp()`, `isAppRunning()`, and +`createAppMonitor()` runner methods should be removed once all callers are +switched to `AppSession`. + +Session events should stay minimal: + +```ts +type AppSessionEvent = { type: 'app_exited' }; + +type AppSessionListener = (event: AppSessionEvent) => void; +``` + +## Crash Investigation Workflow + +Minimal flow: + +1. Bridge disconnect starts crash resolution. +2. Crash monitor waits a short configurable settle window. +3. Crash monitor checks the cached state via `appSession.getState()`. +4. If the app is dead, emit `NativeCrashError` and attach matching crash-indicator log lines if available. +5. If the app is still alive, emit runtime-disconnect error. + +`getState()` replaces `isAppRunning()` on `AppSession`, but it is not an active +polling API. It should return `status: 'exited'` when the session has observed an +exit event or otherwise observed that the launched process is gone. + +Suggested internal shape: + +```ts +type PendingCrash = { + testFilePath: string; + phase: NativeCrashPhase; + occurredAt: number; +}; +``` + +## Error Model + +- Keep `NativeCrashError` as the user-facing error for confirmed native crashes. +- Add one sibling error for bridge or runtime disconnect without confirmed native crash. +- Do not mutate error instances while the investigation is running. +- Crash resolution should keep only the minimal pending context and emit one final error when classification settles. +- `execute-run.ts` should classify both confirmed crashes and runtime-disconnect failures as runtime failures. +- If the app is dead but no explicit crash text is found, the result is still `NativeCrashError` with weaker evidence. + +Suggested user-facing types and error classes: + +```ts +type NativeCrashDetails = { + phase: NativeCrashPhase; + summary: string; + rawLines?: string[]; +}; + +type RuntimeDisconnectDetails = { + phase: NativeCrashPhase; + summary: string; + rawLines?: string[]; +}; + +type HarnessRuntimeFailure = NativeCrashError | RuntimeDisconnectError; + +class NativeCrashError extends Error { + constructor( + public readonly testFilePath: string, + public readonly details: NativeCrashDetails, + ) { + super(buildNativeCrashMessage(details)); + this.name = 'NativeCrashError'; + this.stack = `${this.name}: ${this.message.split('\n')[0]}`; + } +} + +class RuntimeDisconnectError extends Error { + constructor( + public readonly testFilePath: string, + public readonly details: RuntimeDisconnectDetails, + ) { + super(buildRuntimeDisconnectMessage(details)); + this.name = 'RuntimeDisconnectError'; + this.stack = `${this.name}: ${this.message.split('\n')[0]}`; + } +} +``` + +`NativeCrashError` should continue to represent confirmed app death. The new +runtime-disconnect error should represent bridge loss while the app still looks +alive. It is not a crash evidence strength signal; if the app is dead, the +result is `NativeCrashError` even when no crash log lines were found. + +## Restart And Teardown Rules + +- `harness-session.ts` should own the current `AppSession`. +- Restart should be implemented by disposing the current session and creating a new one. +- Crash investigation must be suppressed during intentional restart and teardown. +- Session disposal should mark an explicit intentional-shutdown state before killing the app. +- Any exit, bridge disconnect, or late log signal from an intentionally closed session must be ignored. + +Suggested harness-side ownership model: + +```ts +type HarnessSessionState = { + appSession: AppSession | null; + suppressCrashDetection: boolean; +}; +``` + +## Platform Notes + +- Both simulator and physical-device sessions are assumed to provide bounded run logs that already include crash output. +- This plan does not require a separate unified-log listener for crash detection. +- The intended implementation is to capture logs from the launch-attached stream for the app session. +- v1 should not collect or attach iOS `.ips` / `.crash` artifacts. That can be added back as a later enrichment stage if session logs are not enough. +- Matching crash-indicator lines can be detected using the same kind of regex-based heuristics the codebase already uses today. +- iOS should be the first real implementation target for session-scoped logs, exit observation, and crash evidence. +- Android should be adapted to compile against `createAppSession()` but should not attempt real crash detection yet. +- The Android no-op evidence session should preserve launch, state, and disposal behavior while returning an empty log buffer and emitting no crash-evidence events. +- On Android, bridge disconnect plus `getState().status === 'exited'` should still classify as `NativeCrashError`; only Android log/artifact evidence is deferred. + +## Deliverable Stages + +### Stage 1: Shared Session Contract + +Deliverable: + +- Add shared `AppSessionLog`, `AppSessionEvent`, `AppSessionListener`, and `AppSession` type aliases. +- Replace the runner contract with `createAppSession(options?)`, `dispose()`, and optional `collectNativeCoverage()`. +- Remove `AppMonitor` from the desired public platform contract, but keep any transitional local adapters private if they reduce risk during the migration. +- Add factory helpers for no-op sessions and bounded log buffers if they are useful across platforms. + +Acceptance: + +- `packages/platforms` exports the new session types. +- No new interfaces or classes are introduced. +- Existing platform packages can be migrated one at a time without widening the final public contract. + +### Stage 2: iOS Session Factories + +Deliverable: + +- Add iOS simulator and physical-device session factories. +- Move app launch, launch-attached log capture, bounded log storage, exit observation, `getState()`, and `dispose()` into those session objects. +- Make `createAppSession()` resolve only after launch log capture and exit observation are ready. +- Keep `dispose()` as the intentional app-kill path and ignore late signals from disposed sessions. + +Acceptance: + +- iOS runners no longer expose `startApp()`, `restartApp()`, `stopApp()`, `isAppRunning()`, or `createAppMonitor()` through the shared contract. +- Session logs are scoped to the launched app session. +- Early launch failures surface before `createAppSession()` resolves. +- iOS v1 does not collect or attach `.ips` / `.crash` artifacts. + +### Stage 3: Android No-Op Session Compatibility + +Deliverable: + +- Update Android runners to expose `createAppSession()` with a no-op evidence implementation. +- Preserve current Android launch, state checks, stop-on-dispose, runtime configuration, permission grant, and emulator cleanup behavior. +- Return an empty log buffer from `getLogs()`. +- Do not wire logcat crash parsing, crash artifact collection, or Android crash indicators into the new session flow yet. + +Acceptance: + +- Android compiles and can still launch/restart via the harness-level session lifecycle. +- Android crash investigation produces no platform-provided evidence for now, but bridge disconnect plus exited state still reports `NativeCrashError`. +- Deferred Android work is isolated behind the same `AppSession` contract. + +### Stage 4: Harness Session Ownership + +Deliverable: + +- Update `harness-session.ts` to own the current `AppSession`. +- Replace `restartApp()` usage with `currentSession.dispose()` followed by `runner.createAppSession()`. +- Replace `stopApp()` usage with session disposal. +- Suppress crash investigation during intentional restart, native coverage shutdown, and teardown. + +Acceptance: + +- Restart creates a fresh terminal session. +- Late exit, disconnect, or log signals from intentionally disposed sessions are ignored. +- Native iOS coverage still gets the app shutdown it needs before collection. + +### Stage 5: Crash Monitor Classification + +Deliverable: + +- Add `RuntimeDisconnectError` while keeping error implementations class-based. +- Refactor `crash-monitor.ts` into a factory-created object that consumes the current `AppSession`. +- Start crash investigation on bridge disconnect. +- Wait for the configurable settle window, then classify the disconnect using cached `session.getState()`. +- Emit one final failure: `NativeCrashError` for confirmed app death, `RuntimeDisconnectError` when the app still appears alive. + +Acceptance: + +- Bridge disconnect is the earliest crash signal. +- App death without explicit crash text still becomes a `NativeCrashError`. +- Runtime disconnects no longer masquerade as native crashes. +- The monitor does not mutate error instances while investigation is pending. +- Error call sites can continue to use `instanceof`. + +### Stage 6: Session Log Evidence + +Deliverable: + +- Teach crash resolution to read `appSession.getLogs()`. +- Extract crash-indicator lines near the disconnect or exit time using the existing regex-style heuristics. +- Attach matching raw lines to native crash details when present. +- Keep the fallback summary explicit when app death is confirmed without strong textual evidence. + +Acceptance: + +- iOS crash errors include matching log evidence when the session buffer contains it. +- Missing log evidence weakens the summary but does not prevent native crash classification. +- Android contributes no log evidence in this stage. + +### Stage 7: Execute-Run Runtime Failure Integration + +Deliverable: + +- Update `execute-run.ts` to treat both confirmed native crashes and runtime disconnects as runtime failures. +- Reset crash state after either runtime failure kind when needed. + +Acceptance: + +- Both failure kinds produce user-facing messages with empty Jest failure stacks. +- Existing native crash behavior is preserved for confirmed app death. + +### Stage 8: Tests And Regression Coverage + +Deliverable: + +- Add or update unit tests for shared session contract behavior, iOS session disposal suppression, Android no-op evidence sessions, harness restart ownership, bridge-disconnect classification, runtime-disconnect errors, and log attachment. +- Add focused race-condition tests for disconnect followed by exit, exit followed by disconnect, and intentional restart or teardown. + +Acceptance: + +- Intentional restart and teardown do not report crashes. +- Bridge disconnect plus dead app reports `NativeCrashError`. +- Bridge disconnect plus live app reports `RuntimeDisconnectError`. +- Native crash details include session log lines when available. +- Android no-op evidence behavior is covered so future Android work can replace it safely. + +## Known Risks + +- False positives during intentional restart or teardown if suppression is incomplete. +- Race conditions between bridge disconnect, app exit detection, and buffered log parsing. +- Wrong log attachment if session logs contain noise that is not actually scoped to the launched app. +- Different signal quality between simulator and physical-device console output. +- Long classification waits could make failures feel hung if the timeout constant is too large. + +## Open Tuning Items + +- Exact duration of the buffered recent-log window. +- Exact value of the crash-classification settle timeout. +- Exact name of the non-crash disconnect error type. diff --git a/packages/jest/src/__tests__/crash-monitor.test.ts b/packages/jest/src/__tests__/crash-monitor.test.ts index e1b5d645..73e56963 100644 --- a/packages/jest/src/__tests__/crash-monitor.test.ts +++ b/packages/jest/src/__tests__/crash-monitor.test.ts @@ -14,7 +14,7 @@ import { NativeCrashError, RuntimeDisconnectError } from '../errors.js'; const noop = () => undefined; const waitForClassification = () => - new Promise((resolve) => setTimeout(resolve, 350)); + new Promise((resolve) => setTimeout(resolve, 1600)); const createAppSessionMock = ( initialState: AppSessionState = { status: 'running' }, diff --git a/packages/jest/src/__tests__/harness-session.test.ts b/packages/jest/src/__tests__/harness-session.test.ts new file mode 100644 index 00000000..6a261ae4 --- /dev/null +++ b/packages/jest/src/__tests__/harness-session.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AppConnection } from '@react-native-harness/bridge/server'; +import { waitForBridgeDisconnectOrTimeout } from '../harness-session.js'; + +const createConnection = (): AppConnection => ({ + device: { + platform: 'ios', + manufacturer: 'Apple', + model: 'iPhone', + osVersion: '18.0', + }, + runTests: vi.fn(), +}); + +const createBridge = (connection: AppConnection | null) => { + let currentConnection = connection; + let disconnectedListener: (() => void) | null = null; + + return { + get connection() { + return currentConnection; + }, + on: vi.fn((event: 'disconnected', listener: () => void) => { + if (event === 'disconnected') { + disconnectedListener = listener; + } + }), + off: vi.fn((event: 'disconnected', listener: () => void) => { + if (event === 'disconnected' && disconnectedListener === listener) { + disconnectedListener = null; + } + }), + disconnect: () => { + currentConnection = null; + disconnectedListener?.(); + }, + }; +}; + +describe('waitForBridgeDisconnectOrTimeout', () => { + it('returns true when the bridge disconnects before the timeout', async () => { + const connection = createConnection(); + const bridge = createBridge(connection); + + const waitPromise = waitForBridgeDisconnectOrTimeout({ + bridge, + connection, + timeoutMs: 50, + }); + + bridge.disconnect(); + + await expect(waitPromise).resolves.toBe(true); + }); + + it('returns false when the bridge stays connected through the timeout', async () => { + const connection = createConnection(); + const bridge = createBridge(connection); + + await expect( + waitForBridgeDisconnectOrTimeout({ + bridge, + connection, + timeoutMs: 10, + }), + ).resolves.toBe(false); + }); +}); diff --git a/packages/jest/src/crash-monitor.ts b/packages/jest/src/crash-monitor.ts index cecd202a..781144b9 100644 --- a/packages/jest/src/crash-monitor.ts +++ b/packages/jest/src/crash-monitor.ts @@ -15,7 +15,7 @@ import { import { logger } from '@react-native-harness/tools'; const crashLogger = logger.child('crash'); -const CRASH_CLASSIFICATION_SETTLE_MS = 300; +const CRASH_CLASSIFICATION_SETTLE_MS = 1500; const CRASH_LOG_WINDOW_MS = 3000; export class CrashWatchCancelledError extends Error { diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index a215064f..d8faaf72 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -75,6 +75,48 @@ import { const sessionLogger = logger.child('runtime'); const defaultResourceLockManager = createResourceLockManager(); const ignorePromiseRejection = () => undefined; +const isBridgeDisconnectError = (error: unknown) => + error instanceof Error && error.message === 'App bridge disconnected'; +const TEST_RUN_BRIDGE_STABILITY_WAIT_MS = 500; + +type DisconnectObservableBridge = { + readonly connection: AppConnection | null; + on: (event: 'disconnected', listener: () => void) => void; + off: (event: 'disconnected', listener: () => void) => void; +}; + +export const waitForBridgeDisconnectOrTimeout = async ({ + bridge, + connection, + timeoutMs, +}: { + bridge: DisconnectObservableBridge; + connection: AppConnection; + timeoutMs: number; +}): Promise => { + if (bridge.connection !== connection) { + return true; + } + + return await new Promise((resolve) => { + const onDisconnected = () => { + cleanup(); + resolve(true); + }; + + const cleanup = () => { + clearTimeout(timer); + bridge.off('disconnected', onDisconnected); + }; + + const timer = setTimeout(() => { + cleanup(); + resolve(bridge.connection !== connection); + }, timeoutMs); + + bridge.on('disconnected', onDisconnected); + }); +}; export type HarnessRunState = { readonly runId: string; @@ -709,12 +751,31 @@ export const createHarnessSession = async ( // crash wins the race or cancel() is called after the test run wins. crashWatch.promise.catch(ignorePromiseRejection); try { - const result = await Promise.race([ - conn.runTests(testPath, { ...options, runner: platform.runner }), - crashWatch.promise, - ]); + const testRunPromise = conn.runTests(testPath, { + ...options, + runner: platform.runner, + }); + const result = await Promise.race([testRunPromise, crashWatch.promise]); + const bridgeDisconnected = await waitForBridgeDisconnectOrTimeout({ + bridge, + connection: conn, + timeoutMs: TEST_RUN_BRIDGE_STABILITY_WAIT_MS, + }); + + if (bridgeDisconnected) { + return await crashWatch.promise; + } + await hooks.drain(); return result; + } catch (error) { + if (isBridgeDisconnectError(error)) { + const result = await crashWatch.promise; + await hooks.drain(); + return result; + } + + throw error; } finally { crashWatch.cancel(); } From 06bc8625338302bfd2a2ddf322b94c9cd98e2b29 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 12:50:47 +0200 Subject: [PATCH 03/17] feat(android): add crash-aware app sessions --- ANDROID_CRASH.md | 274 +++++++++++++++++ .../com/harnessplayground/MainApplication.kt | 3 +- .../PlaygroundCrashModule.kt | 47 +++ .../PlaygroundCrashPackage.kt | 29 ++ .../project.pbxproj | 10 + .../PlaygroundSwiftCrash.swift | 14 + .../HarnessPlayground/RCTPlaygroundCrash.h | 6 + .../HarnessPlayground/RCTPlaygroundCrash.mm | 81 +++++ apps/playground/ios/Podfile.lock | 8 +- apps/playground/package.json | 13 + .../src/__tests__/crash-test.harness.ts | 56 ++++ apps/playground/src/native/PlaygroundCrash.ts | 1 + .../src/specs/NativePlaygroundCrash.ts | 13 + .../src/__tests__/instance.test.ts | 197 +++++++++++-- packages/platform-android/src/adb.ts | 16 +- packages/platform-android/src/app-session.ts | 279 ++++++++++++++++++ packages/platform-android/src/instance.ts | 82 +---- packages/platform-android/test/setup.ts | 1 + 18 files changed, 1023 insertions(+), 107 deletions(-) create mode 100644 ANDROID_CRASH.md create mode 100644 apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt create mode 100644 apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt create mode 100644 apps/playground/ios/HarnessPlayground/PlaygroundSwiftCrash.swift create mode 100644 apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.h create mode 100644 apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.mm create mode 100644 apps/playground/src/__tests__/crash-test.harness.ts create mode 100644 apps/playground/src/native/PlaygroundCrash.ts create mode 100644 apps/playground/src/specs/NativePlaygroundCrash.ts create mode 100644 packages/platform-android/src/app-session.ts diff --git a/ANDROID_CRASH.md b/ANDROID_CRASH.md new file mode 100644 index 00000000..ace1e119 --- /dev/null +++ b/ANDROID_CRASH.md @@ -0,0 +1,274 @@ +# Android Crash Detection Plan + +## Goal + +Implement Android `AppSession` crash detection using `adb` polling for liveness and `logcat` streaming for evidence, while preserving the same harness-level behavior introduced by the iOS crash-detection refactor. + +The Android implementation should: + +- treat bridge disconnect as the earliest crash signal +- classify bridge disconnect plus exited app state as `NativeCrashError` +- classify bridge disconnect plus still-running app state as `RuntimeDisconnectError` +- attach Android log evidence when it is available +- tolerate the fact that Android app launch is not a blocking, console-attached process + +## Decisions + +- Android should keep the shared `AppSession` contract introduced by `CRASH_PLAN.md`. +- Android app liveness should be driven by polling, not by a blocking launch handle. +- Android crash evidence should come from a session-owned bounded `logcat` buffer. +- Android should use a device-side pre-launch timestamp to reduce the race where the app crashes before the host-side `logcat` reader is attached. +- Android should start session log capture with `--uid=` because PID is not known at launch time. +- We can assume there is only one process for the target app, so `--uid=` is sufficiently app-scoped for bootstrap capture. +- `pidof` should still be used for liveness, cached state, and optional PID attachment to session state. +- Switching the log stream from `--uid` to `--pid` after PID discovery is optional, not required for v1. +- `createAppSession()` should resolve only after log capture and exit observation are attached, the app launch command has completed, and early launch failure has already surfaced. +- `dispose()` should remain the intentional kill path and must suppress late exit or log signals. +- Android should prefer `logcat` session evidence over separate crash artifact collection in v1 of this plan. + +## Why Android Differs From iOS + +- iOS can treat the launch-attached process stream as both launch control and session log source. +- Android `adb shell am start ...` is only a start command. It does not remain attached to the app process. +- Android therefore needs two separate observation channels: + - polling for app liveness + - `logcat` streaming for crash evidence + +## Startup Race Mitigation + +The main Android-specific risk is that the app can crash before the host starts consuming `logcat` output. + +To reduce that race: + +1. Read a device-side timestamp before launch. +2. Start `logcat` with `-T ` so Android replays buffered lines from that time onward. +3. Scope the bootstrap stream with `--uid=`. +4. Launch the app. +5. Poll `pidof` until the process appears or early launch failure is detected. + +This does not make the race mathematically impossible, because log buffers can still rotate, but it should recover immediate startup crash lines in the normal case. + +Suggested bootstrap shape: + +```ts +const sessionStartTimestamp = await adb.getLogcatTimestamp(adbId); + +const logcatProcess = adb.startLogcat(adbId, [ + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + `--uid=${appUid}`, + '-T', + sessionStartTimestamp, +]); + +await adb.startApp(adbId, bundleId, activityName, launchOptions); +``` + +If the app dies before PID is ever observed, the crash monitor should still be able to classify it as a native crash from: + +- session state becoming `exited` +- buffered `logcat` evidence captured via `-T ` + +As a fallback, Android may also do one final dump query using the same lower bound before final classification if the streaming path produced no lines. + +## AppSession Responsibilities + +Android `AppSession` should own: + +- launch command execution via `adb startApp(...)` +- a bounded session log buffer populated from `logcat` +- cached app-session state +- exit notification +- disposal + +Suggested Android shape remains the shared shape: + +```ts +type AppSession = { + dispose: () => Promise; + getState: () => Promise; + getLogs: () => AppSessionLog[]; + addListener: (listener: AppSessionListener) => void; + removeListener: (listener: AppSessionListener) => void; +}; +``` + +Suggested Android state behavior: + +- initial state should become `{ status: 'running', pid?: number }` once launch observation is attached and the app is considered started +- when polling observes the process is gone, state should become `status: 'exited'` +- `pid` should be attached when discovered and preserved on exit when available +- `getState()` should return cached state only and must not call `adb` + +## Android Observation Flow + +Minimal session flow: + +1. Stop any old app process before launch. +2. Read device timestamp via `adb.getLogcatTimestamp(adbId)`. +3. Start session-owned `logcat` with `-T ` and `--uid=`. +4. Start the app with `adb.startApp(...)`. +5. Poll `pidof` until the process appears, or until it is clear launch already failed. +6. Start the steady-state liveness poll. +7. Push all `logcat` lines into the bounded session log buffer. +8. When polling observes the process is gone, update cached state and emit `app_exited`. + +Suggested polling responsibilities: + +- one early-launch poll window to confirm the app actually appeared +- one steady-state poll loop to detect process death after startup +- polling remains the source of truth for `running` vs `exited` + +## Log Scoping Strategy + +For this repo we can assume one process per app. + +That means: + +- `--uid=` is enough to scope bootstrap logs to this app session in practice +- PID filtering is a refinement, not a requirement + +Recommended v1 strategy: + +- start with one session-long `logcat` stream using `--uid=` +- store all streamed lines in the bounded session buffer +- optionally parse PID-bearing lines and update cached PID when they appear + +Optional later refinement: + +- after PID is known, restart `logcat` with `--pid= -T ` if we want tighter filtering + +That refinement is optional because it increases session complexity and is not necessary under the single-process assumption. + +## Evidence Strategy + +Android crash evidence should come from the `AppSession` log buffer. + +The implementation can reuse the same style of heuristics already present in the old Android monitor: + +- fatal exception markers +- `Process: , PID: ` lines +- native crash markers such as `>>> <<<` +- `Process (pid ) has died` +- signal markers such as `SIGSEGV` or `signal 11` + +Crash monitor integration should stay the same as on iOS: + +1. bridge disconnect starts crash resolution +2. settle briefly +3. read cached session state +4. if state is `exited`, emit `NativeCrashError` +5. if state is still `running`, emit `RuntimeDisconnectError` +6. attach matching log lines from `appSession.getLogs()` when available + +If the app is dead and logs are empty or weak, the result should still be `NativeCrashError` with a weaker summary. + +## Early Launch Failure Rules + +`createAppSession()` should not resolve if the launch clearly failed before the app became observable. + +Android should treat these as early launch failures: + +- `adb.startApp(...)` fails +- the app never appears in the early PID poll window +- the app appears and then immediately disappears before session startup settles + +The exact settle duration can be tuned later, similar to iOS `LAUNCH_FAILURE_SETTLE_MS`. + +## Restart And Teardown Rules + +- restart should dispose the current Android session and create a fresh one +- disposal must stop the app, stop the `logcat` reader, stop polling, and suppress late signals +- intentional restart and teardown must not surface native crash or runtime-disconnect failures + +## Deliverable Stages + +### Stage 1: Android Session Factory + +Deliverable: + +- Replace the current Android no-op evidence session with a real Android session factory. +- Move Android launch, session-owned log capture, PID discovery, cached state, exit polling, and disposal into that session object. +- Keep the shared `AppSession` public contract unchanged. + +Acceptance: + +- Android still compiles against the same shared session types. +- `createAppSession()` returns a session with real logs and cached exit state. +- `dispose()` still force-stops the app. + +### Stage 2: Startup Race Handling + +Deliverable: + +- Capture a device-side timestamp before launch. +- Start `logcat` with `-T ` and `--uid=` before `adb.startApp(...)`. +- Add an early-launch PID observation window before resolving the session. + +Acceptance: + +- Immediate startup crashes can still produce session log evidence in the common case. +- `createAppSession()` rejects when launch clearly fails before startup is established. + +### Stage 3: Crash Evidence Integration + +Deliverable: + +- Teach Android crash classification to read `appSession.getLogs()`. +- Reuse or adapt the old Android crash-line heuristics for session-buffer parsing. +- Attach matching raw lines to `NativeCrashError` when available. + +Acceptance: + +- Android bridge disconnect plus exited state reports `NativeCrashError`. +- Android bridge disconnect plus live app reports `RuntimeDisconnectError`. +- Android crash errors include matching `logcat` lines when they exist. + +### Stage 4: Optional PID Refinement + +Deliverable: + +- Decide whether restarting `logcat` with `--pid=` after PID discovery is worth the extra complexity. +- If implemented, preserve the same session start lower bound and avoid losing early buffered lines. + +Acceptance: + +- The decision is explicit. +- If omitted, `--uid` remains the supported strategy. +- If implemented, PID-specific log capture does not regress startup crash visibility. + +### Stage 5: Tests And Race Coverage + +Deliverable: + +- Add unit tests for Android session startup, PID discovery, exit polling, log buffering, and disposal suppression. +- Add focused race tests for: + - crash before PID discovery + - PID discovered then process exits + - bridge disconnect before poll notices exit + - intentional restart and teardown suppression + +Acceptance: + +- Android startup race mitigation is covered. +- Session state and log buffering remain stable across restart and teardown. +- Crash classification matches the shared harness behavior. + +## Known Risks + +- `logcat` buffers can rotate before evidence is read in extreme cases. +- `--uid=` is assumed to be app-scoped enough under the single-process assumption. +- Polling-based exit detection can lag real process death by up to the poll interval. +- Reattaching `logcat` on PID discovery could introduce avoidable complexity and regressions. +- Different Android versions may vary in `logcat` option support or output details. + +## Open Tuning Items + +- Exact early-launch PID settle timeout. +- Exact steady-state poll interval. +- Whether to read only `-b crash` or include additional buffers. +- Whether to keep `--uid` for the whole session or switch to `--pid` after PID discovery. +- Whether to perform a final `logcat -d -T ` fallback read before classifying an evidence-light crash. diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt index cfa2bec2..d0632ee7 100644 --- a/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/MainApplication.kt @@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication { context = applicationContext, packageList = PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(PlaygroundCrashPackage()) }, ) } diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt new file mode 100644 index 00000000..92605a0f --- /dev/null +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashModule.kt @@ -0,0 +1,47 @@ +package com.harnessplayground + +import android.os.Handler +import android.os.Looper +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule + +@DoNotStrip +@ReactModule(name = NativePlaygroundCrashSpec.NAME) +class PlaygroundCrashModule(reactContext: ReactApplicationContext) : + NativePlaygroundCrashSpec(reactContext) { + + override fun crashFromObjectiveCSync(message: String): Boolean { + throw UnsupportedOperationException( + "Objective-C crash is only available on iOS. Requested message: $message", + ) + } + + override fun crashFromObjectiveCAsync(message: String) { + throw UnsupportedOperationException( + "Objective-C crash is only available on iOS. Requested message: $message", + ) + } + + override fun crashFromSwiftSync(message: String): Boolean { + throw UnsupportedOperationException( + "Swift crash is only available on iOS. Requested message: $message", + ) + } + + override fun crashFromSwiftAsync(message: String) { + throw UnsupportedOperationException( + "Swift crash is only available on iOS. Requested message: $message", + ) + } + + override fun crashFromKotlinSync(message: String): Boolean { + throw RuntimeException("Intentional synchronous Kotlin crash: $message") + } + + override fun crashFromKotlinAsync(message: String) { + Handler(Looper.getMainLooper()).post { + throw RuntimeException("Intentional asynchronous Kotlin crash: $message") + } + } +} diff --git a/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt new file mode 100644 index 00000000..470bd219 --- /dev/null +++ b/apps/playground/android/app/src/main/java/com/harnessplayground/PlaygroundCrashPackage.kt @@ -0,0 +1,29 @@ +package com.harnessplayground + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class PlaygroundCrashPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == NativePlaygroundCrashSpec.NAME) { + PlaygroundCrashModule(reactContext) + } else { + null + } + + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { + mapOf( + NativePlaygroundCrashSpec.NAME to ReactModuleInfo( + NativePlaygroundCrashSpec.NAME, + PlaygroundCrashModule::class.java.name, + false, + false, + false, + true, + ), + ) + } +} diff --git a/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj b/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj index 331591b5..a7aa77f7 100644 --- a/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj +++ b/apps/playground/ios/HarnessPlayground.xcodeproj/project.pbxproj @@ -9,9 +9,11 @@ /* Begin PBXBuildFile section */ 0C80B921A6F3F58F76C31292 /* libPods-HarnessPlayground.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-HarnessPlayground.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 14E8353EBA4BACCD574114DE /* PlaygroundSwiftCrash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AE27A5EB682B6DCF9372AB /* PlaygroundSwiftCrash.swift */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 7B16895B86EC58090B0C2218 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + D54969D45B5AA0766A189885 /* RCTPlaygroundCrash.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5007C986775F98C67667E633 /* RCTPlaygroundCrash.mm */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -20,8 +22,11 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HarnessPlayground/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = HarnessPlayground/PrivacyInfo.xcprivacy; sourceTree = ""; }; 3B4392A12AC88292D35C810B /* Pods-HarnessPlayground.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HarnessPlayground.debug.xcconfig"; path = "Target Support Files/Pods-HarnessPlayground/Pods-HarnessPlayground.debug.xcconfig"; sourceTree = ""; }; + 5007C986775F98C67667E633 /* RCTPlaygroundCrash.mm */ = {isa = PBXFileReference; includeInIndex = 1; name = RCTPlaygroundCrash.mm; path = HarnessPlayground/RCTPlaygroundCrash.mm; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-HarnessPlayground.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HarnessPlayground.release.xcconfig"; path = "Target Support Files/Pods-HarnessPlayground/Pods-HarnessPlayground.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-HarnessPlayground.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HarnessPlayground.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5ED740C7C3F9A8F8CC6EB8D9 /* RCTPlaygroundCrash.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = RCTPlaygroundCrash.h; path = HarnessPlayground/RCTPlaygroundCrash.h; sourceTree = ""; }; + 64AE27A5EB682B6DCF9372AB /* PlaygroundSwiftCrash.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PlaygroundSwiftCrash.swift; path = HarnessPlayground/PlaygroundSwiftCrash.swift; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HarnessPlayground/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HarnessPlayground/LaunchScreen.storyboard; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -47,6 +52,9 @@ 13B07FB61A68108700A75B9A /* Info.plist */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + 5ED740C7C3F9A8F8CC6EB8D9 /* RCTPlaygroundCrash.h */, + 5007C986775F98C67667E633 /* RCTPlaygroundCrash.mm */, + 64AE27A5EB682B6DCF9372AB /* PlaygroundSwiftCrash.swift */, ); name = HarnessPlayground; sourceTree = ""; @@ -247,6 +255,8 @@ buildActionMask = 2147483647; files = ( 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + D54969D45B5AA0766A189885 /* RCTPlaygroundCrash.mm in Sources */, + 14E8353EBA4BACCD574114DE /* PlaygroundSwiftCrash.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/playground/ios/HarnessPlayground/PlaygroundSwiftCrash.swift b/apps/playground/ios/HarnessPlayground/PlaygroundSwiftCrash.swift new file mode 100644 index 00000000..44916a4b --- /dev/null +++ b/apps/playground/ios/HarnessPlayground/PlaygroundSwiftCrash.swift @@ -0,0 +1,14 @@ +import Foundation + +@objcMembers +final class PlaygroundSwiftCrash: NSObject { + func crashSync(message: String) { + fatalError("Intentional Swift crash: \(message)") + } + + func crashAsync(message: String) { + DispatchQueue.main.async { + fatalError("Intentional Swift crash: \(message)") + } + } +} diff --git a/apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.h b/apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.h new file mode 100644 index 00000000..74279c8b --- /dev/null +++ b/apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.h @@ -0,0 +1,6 @@ +#import + +#import + +@interface RCTPlaygroundCrash : NativePlaygroundCrashSpecBase +@end diff --git a/apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.mm b/apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.mm new file mode 100644 index 00000000..e7e3150e --- /dev/null +++ b/apps/playground/ios/HarnessPlayground/RCTPlaygroundCrash.mm @@ -0,0 +1,81 @@ +#import "RCTPlaygroundCrash.h" +#import "RCTDefaultReactNativeFactoryDelegate.h" +#import "HarnessPlayground-Swift.h" + +#import +#import + +using namespace facebook::react; + +@implementation RCTPlaygroundCrash { + PlaygroundSwiftCrash *_swiftCrash; +} + +RCT_EXPORT_MODULE(PlaygroundCrash) + +- (instancetype)init +{ + if (self = [super init]) { + _swiftCrash = [PlaygroundSwiftCrash new]; + } + return self; +} + +- (std::shared_ptr)getTurboModule:(const ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +- (NSNumber *)crashFromObjectiveCSync:(NSString *)message +{ + @throw [NSException exceptionWithName:@"HarnessPlaygroundObjectiveCCrash" + reason:[NSString stringWithFormat:@"Intentional Objective-C crash: %@", message] + userInfo:nil]; + + return @NO; +} + +- (void)crashFromObjectiveCAsync:(NSString *)message +{ + dispatch_async(dispatch_get_main_queue(), ^{ + @throw [NSException exceptionWithName:@"HarnessPlaygroundObjectiveCCrash" + reason:[NSString stringWithFormat:@"Intentional Objective-C crash: %@", message] + userInfo:nil]; + }); +} + +- (NSNumber *)crashFromSwiftSync:(NSString *)message +{ + [_swiftCrash crashSyncWithMessage:message]; + + return @NO; +} + +- (void)crashFromSwiftAsync:(NSString *)message +{ + [_swiftCrash crashAsyncWithMessage:message]; +} + +- (NSNumber *)crashFromKotlinSync:(NSString *)message +{ + RCTFatal([NSError errorWithDomain:@"HarnessPlaygroundUnsupportedCrash" + code:1 + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"Kotlin crash is only available on Android. Requested message: %@", message] + }]); + + return @NO; +} + +- (void)crashFromKotlinAsync:(NSString *)message +{ + RCTFatal([NSError errorWithDomain:@"HarnessPlaygroundUnsupportedCrash" + code:1 + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"Kotlin crash is only available on Android. Requested message: %@", message] + }]); +} + +@end diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index 1a535f22..18b1af40 100644 --- a/apps/playground/ios/Podfile.lock +++ b/apps/playground/ios/Podfile.lock @@ -5,7 +5,7 @@ PODS: - FBLazyVector (0.82.1) - fmt (11.0.2) - glog (0.3.5) - - HarnessUI (1.1.0): + - HarnessUI (1.2.0): - boost - DoubleConversion - fast_float @@ -2691,7 +2691,7 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - HarnessUI: 01740b858c62c55d42995d4ca459ead036b96c9a + HarnessUI: c5f2b106cfb3944569b791515e304b5e96d63bb6 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 NitroImage: dfec7a8d5e6ba8228ed780bc70041e762cbbbd0b NitroModules: b24827b7772f5a030aef074547a2393a6e03579e @@ -2758,12 +2758,12 @@ SPEC CHECKSUMS: React-utils: abf37b162f560cd0e3e5d037af30bb796512246d React-webperformancenativemodule: 50a57c713a90d27ae3ab947a6c9c8859bcb49709 ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176 - ReactCodegen: 65ae48ae967a383859da021028e6e8dd7b2d97d1 + ReactCodegen: 7cbd647ef54597eb03252f261ce11338f72c1576 ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 VisionCamera: 889238ad98665463fcc2fa44385614979263cfc7 Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb -PODFILE CHECKSUM: 0a1696308b49d81f7b7a744c9ae31d90de903a3e +PODFILE CHECKSUM: 39c2ddc2e50df4393825350ad71964fe38e517c6 COCOAPODS: 1.16.2 diff --git a/apps/playground/package.json b/apps/playground/package.json index 0daa879f..5af8713c 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -5,6 +5,19 @@ "scripts": { "test:harness": "jest --selectProjects react-native-harness" }, + "codegenConfig": { + "name": "PlaygroundCrashSpec", + "type": "modules", + "jsSrcsDir": "src/specs", + "android": { + "javaPackageName": "com.harnessplayground" + }, + "ios": { + "modulesProvider": { + "PlaygroundCrash": "RCTPlaygroundCrash" + } + } + }, "dependencies": { "react": "19.2.3", "react-dom": "19.2.3", diff --git a/apps/playground/src/__tests__/crash-test.harness.ts b/apps/playground/src/__tests__/crash-test.harness.ts new file mode 100644 index 00000000..981045a1 --- /dev/null +++ b/apps/playground/src/__tests__/crash-test.harness.ts @@ -0,0 +1,56 @@ +import { Platform } from 'react-native'; +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../native/PlaygroundCrash'; + +if (Platform.OS === 'ios') { + describe('iOS crashes', () => { + it('objc sync', () => { + console.log('before objc sync'); + PlaygroundCrash.crashFromObjectiveCSync( + 'crash-test.harness.ts objc sync', + ); + alert('after objc sync'); + }); + + it('objc async', () => { + console.log('before objc async'); + PlaygroundCrash.crashFromObjectiveCAsync( + 'crash-test.harness.ts objc async', + ); + alert('after objc async'); + }); + + it('swift sync', () => { + console.log('before swift sync'); + PlaygroundCrash.crashFromSwiftSync('crash-test.harness.ts swift sync'); + alert('after swift sync'); + }); + + it('swift async', () => { + console.log('before swift async'); + PlaygroundCrash.crashFromSwiftAsync( + 'crash-test.harness.ts swift async', + ); + alert('after swift async'); + }); + }); +} + +if (Platform.OS === 'android') { + describe('Android crashes', () => { + it('kotlin sync', () => { + console.log('before kotlin sync'); + PlaygroundCrash.crashFromKotlinSync('crash-test.harness.ts kotlin sync'); + alert('after kotlin sync'); + }); + + it('kotlin async', () => { + console.log('before kotlin async'); + PlaygroundCrash.crashFromKotlinAsync( + 'crash-test.harness.ts kotlin async', + ); + alert('after kotlin async'); + }); + }); +} diff --git a/apps/playground/src/native/PlaygroundCrash.ts b/apps/playground/src/native/PlaygroundCrash.ts new file mode 100644 index 00000000..597e2048 --- /dev/null +++ b/apps/playground/src/native/PlaygroundCrash.ts @@ -0,0 +1 @@ +export { default } from '../specs/NativePlaygroundCrash'; diff --git a/apps/playground/src/specs/NativePlaygroundCrash.ts b/apps/playground/src/specs/NativePlaygroundCrash.ts new file mode 100644 index 00000000..091eefeb --- /dev/null +++ b/apps/playground/src/specs/NativePlaygroundCrash.ts @@ -0,0 +1,13 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + crashFromObjectiveCSync(message: string): boolean; + crashFromObjectiveCAsync(message: string): void; + crashFromSwiftSync(message: string): boolean; + crashFromSwiftAsync(message: string): void; + crashFromKotlinSync(message: string): boolean; + crashFromKotlinAsync(message: string): void; +} + +export default TurboModuleRegistry.getEnforcing('PlaygroundCrash'); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 5985753f..ac40202b 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_METRO_PORT, type Config as HarnessConfig, } from '@react-native-harness/config'; +import type { Subprocess } from '@react-native-harness/tools'; import { getAndroidEmulatorPlatformInstance, getAndroidPhysicalDevicePlatformInstance, @@ -12,6 +13,21 @@ import * as avdConfig from '../avd-config.js'; import * as sharedPrefs from '../shared-prefs.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; +const createLogcatProcess = (lines: string[] = []): Subprocess => { + const process = { + nodeChildProcess: Promise.resolve({ + kill: vi.fn(), + }), + [Symbol.asyncIterator]: async function* () { + for (const line of lines) { + yield line; + } + }, + }; + + return process as unknown as Subprocess; +}; + const harnessConfig = { metroPort: DEFAULT_METRO_PORT, } as HarnessConfig; @@ -27,6 +43,7 @@ describe('Android platform instance', () => { beforeEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + vi.useRealTimers(); vi.spyOn( await import('../environment.js'), 'ensureAndroidEmulatorAvailable', @@ -47,6 +64,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); @@ -98,6 +118,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); @@ -159,6 +182,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); @@ -215,6 +241,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); @@ -276,6 +305,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); @@ -343,6 +375,9 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); @@ -466,7 +501,8 @@ describe('Android platform instance', () => { ).rejects.toBeInstanceOf(HarnessEmulatorConfigError); }); - it('returns a noop emulator app monitor when native crash detection is disabled', async () => { + it('creates a real emulator app session when native crash detection is disabled', async () => { + vi.useFakeTimers(); vi.spyOn( await import('../environment.js'), 'ensureAndroidEmulatorEnvironment', @@ -478,9 +514,18 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); + const stopApp = vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + const startApp = vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); + const getAppPid = vi.spyOn(adb, 'getAppPid').mockResolvedValue(4321); + const startLogcat = vi + .spyOn(adb, 'startLogcat') + .mockReturnValue(createLogcatProcess()); const instance = await getAndroidEmulatorPlatformInstance( { @@ -502,20 +547,108 @@ describe('Android platform instance', () => { init, ); + const listener = vi.fn(); + const appSession = await instance.createAppSession(); + appSession.addListener(listener); + await vi.advanceTimersByTimeAsync(0); + + expect(startLogcat).toHaveBeenCalledWith('emulator-5554', [ + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + '--uid=10234', + '-T', + '01-01 00:00:00.000', + ]); + expect(startLogcat.mock.invocationCallOrder[0]).toBeLessThan( + startApp.mock.invocationCallOrder[0], + ); + await expect(appSession.getState()).resolves.toEqual({ + status: 'running', + pid: 4321, + }); + expect(getAppPid).toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + expect(appSession.removeListener(listener)).toBeUndefined(); + await expect(appSession.dispose()).resolves.toBeUndefined(); + expect(stopApp).toHaveBeenCalled(); + }); + + it('reports an early Android crash from logcat before a PID poll succeeds', async () => { + vi.useFakeTimers(); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment', + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined, + ); vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); - vi.spyOn(adb, 'isAppRunning').mockResolvedValue(false); + vi.spyOn(adb, 'getAppPid').mockResolvedValue(null); + vi.spyOn(adb, 'startLogcat').mockReturnValue( + createLogcatProcess([ + '--------- beginning of crash', + 'Process: com.harnessplayground, PID: 7777', + 'FATAL EXCEPTION: main', + ]), + ); + + const instance = await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init, + ); const listener = vi.fn(); const appSession = await instance.createAppSession(); + appSession.addListener(listener); + + await vi.advanceTimersByTimeAsync(0); + + expect(appSession.getLogs().map((entry) => entry.line)).toEqual([ + '--------- beginning of crash', + 'Process: com.harnessplayground, PID: 7777', + 'FATAL EXCEPTION: main', + ]); + await expect(appSession.getState()).resolves.toMatchObject({ + status: 'exited', + pid: 7777, + reason: 'observed-exit', + }); + expect(listener).toHaveBeenCalledWith({ type: 'app_exited' }); - expect(appSession.getLogs()).toEqual([]); - expect(appSession.addListener(listener)).toBeUndefined(); - expect(appSession.removeListener(listener)).toBeUndefined(); - await expect(appSession.dispose()).resolves.toBeUndefined(); + await appSession.dispose(); }); - it('returns a noop physical device app monitor when native crash detection is disabled', async () => { + it('creates a real physical-device app session when native crash detection is disabled', async () => { + vi.useFakeTimers(); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['012345']); vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({ manufacturer: 'motorola', @@ -525,25 +658,18 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue( + '01-01 00:00:00.000', + ); vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( undefined, ); - - await expect( - getAndroidPhysicalDevicePlatformInstance( - { - name: 'android-device', - device: { - type: 'physical', - manufacturer: 'motorola', - model: 'moto g72', - }, - bundleId: 'com.harnessplayground', - activityName: '.MainActivity', - }, - harnessConfigWithoutNativeCrashDetection, - ), - ).resolves.toBeDefined(); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppPid').mockResolvedValue(8765); + const startLogcat = vi + .spyOn(adb, 'startLogcat') + .mockReturnValue(createLogcatProcess()); const instance = await getAndroidPhysicalDevicePlatformInstance( { @@ -559,15 +685,26 @@ describe('Android platform instance', () => { harnessConfigWithoutNativeCrashDetection, ); - vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); - vi.spyOn(adb, 'startApp').mockResolvedValue(undefined); - vi.spyOn(adb, 'isAppRunning').mockResolvedValue(false); - const listener = vi.fn(); const appSession = await instance.createAppSession(); - - expect(appSession.getLogs()).toEqual([]); - expect(appSession.addListener(listener)).toBeUndefined(); + appSession.addListener(listener); + await vi.advanceTimersByTimeAsync(0); + + expect(startLogcat).toHaveBeenCalledWith('012345', [ + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + '--uid=10234', + '-T', + '01-01 00:00:00.000', + ]); + await expect(appSession.getState()).resolves.toEqual({ + status: 'running', + pid: 8765, + }); + expect(listener).not.toHaveBeenCalled(); expect(appSession.removeListener(listener)).toBeUndefined(); await expect(appSession.dispose()).resolves.toBeUndefined(); }); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 9b943901..bf1cc273 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -699,6 +699,13 @@ export const isAppRunning = async ( adbId: string, bundleId: string, ): Promise => { + return (await getAppPid(adbId, bundleId)) != null; +}; + +export const getAppPid = async ( + adbId: string, + bundleId: string, +): Promise => { try { const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', @@ -707,10 +714,15 @@ export const isAppRunning = async ( 'pidof', bundleId, ]); - return stdout.trim() !== ''; + const pid = stdout + .trim() + .split(/\s+/) + .find((value) => value !== ''); + + return pid ? Number(pid) : null; } catch (error) { if (error instanceof SubprocessError && error.exitCode === 1) { - return false; + return null; } throw error; diff --git a/packages/platform-android/src/app-session.ts b/packages/platform-android/src/app-session.ts new file mode 100644 index 00000000..23a13f29 --- /dev/null +++ b/packages/platform-android/src/app-session.ts @@ -0,0 +1,279 @@ +import { + createAppSessionEmitter, + createBoundedLogBuffer, + type AppSession, + type AppSessionState, +} from '@react-native-harness/platforms'; +import { + escapeRegExp, + logger, + type Subprocess, +} from '@react-native-harness/tools'; + +const androidAppSessionLogger = logger.child('android-app-session'); +const APP_EXIT_POLL_INTERVAL_MS = 1000; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const getLogcatArgs = (appUid: number, fromTime: string) => + [ + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + `--uid=${appUid}`, + '-T', + fromTime, + ] as const; + +const getProcessPattern = (bundleId: string) => + new RegExp(`Process:\\s*${escapeRegExp(bundleId)},\\s*PID:\\s*(\\d+)`); + +const getStartProcPattern = (bundleId: string) => + new RegExp(`Start proc (\\d+):${escapeRegExp(bundleId)}(?:/|\\s)`); + +const getProcessDiedPattern = (bundleId: string) => + new RegExp( + `Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, + 'i', + ); + +const getObservedPid = (line: string, bundleId: string): number | undefined => { + const match = + line.match(getProcessPattern(bundleId)) ?? + line.match(getStartProcPattern(bundleId)) ?? + line.match(getProcessDiedPattern(bundleId)); + + return match?.[1] ? Number(match[1]) : undefined; +}; + +const isCrashSignal = (line: string, bundleId: string): boolean => { + return ( + getProcessPattern(bundleId).test(line) || + new RegExp(`>>>\\s*${escapeRegExp(bundleId)}\\s*<<<`).test(line) || + getProcessDiedPattern(bundleId).test(line) || + (line.includes(bundleId) && /fatal|crash|signal 11|signal 6|backtrace/i.test(line)) + ); +}; + +const stopSubprocess = async (child: Subprocess) => { + try { + (await child.nodeChildProcess).kill(); + } catch { + // Ignore termination failures for already-ended background processes. + } +}; + +const isExitedState = ( + state: AppSessionState, +): state is Extract => + state.status === 'exited'; + +type CreateAndroidAppSessionOptions = { + appUid: number; + bundleId: string; + startApp: () => Promise; + stopApp: () => Promise; + getAppPid: () => Promise; + getLogcatTimestamp: () => Promise; + startLogcat: (args: readonly string[]) => Subprocess; +}; + +export const createAndroidAppSession = async ({ + appUid, + bundleId, + startApp, + stopApp, + getAppPid, + getLogcatTimestamp, + startLogcat, +}: CreateAndroidAppSessionOptions): Promise => { + const emitter = createAppSessionEmitter(); + const logBuffer = createBoundedLogBuffer(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + let hasObservedProcess = false; + let exitNotification: ReturnType | null = null; + let pollDelayTimeout: ReturnType | null = null; + let resolvePollDelay: (() => void) | null = null; + + const getCurrentPid = () => ('pid' in state ? state.pid : undefined); + + const setRunning = (pid?: number) => { + if (disposed || state.status === 'disposed' || state.status === 'exited') { + return; + } + + if (pid !== undefined) { + hasObservedProcess = true; + } + + state = pid === undefined ? { status: 'running' } : { status: 'running', pid }; + }; + + const scheduleExitNotification = () => { + if (exitNotification) { + return; + } + + exitNotification = setTimeout(() => { + exitNotification = null; + + if (!disposed && state.status === 'exited') { + emitter.emit({ type: 'app_exited' }); + } + }, 0); + }; + + const waitForNextPoll = () => + new Promise((resolve) => { + resolvePollDelay = () => { + resolvePollDelay = null; + pollDelayTimeout = null; + resolve(); + }; + + pollDelayTimeout = setTimeout(() => { + resolvePollDelay?.(); + }, APP_EXIT_POLL_INTERVAL_MS); + }); + + const cancelPendingPollDelay = () => { + if (pollDelayTimeout) { + clearTimeout(pollDelayTimeout); + pollDelayTimeout = null; + } + + resolvePollDelay?.(); + }; + + const setExited = ( + reason: 'observed-exit' | 'process-gone', + pid?: number, + ) => { + if (disposed || state.status === 'disposed' || state.status === 'exited') { + return; + } + + state = { + status: 'exited', + occurredAt: Date.now(), + pid: pid ?? getCurrentPid(), + reason, + }; + scheduleExitNotification(); + }; + + const setExitedPid = (pid: number) => { + if (state.status !== 'exited' || state.pid !== undefined) { + return; + } + + state = { + status: 'exited', + occurredAt: state.occurredAt, + reason: state.reason, + pid, + }; + }; + + const logcatTimestamp = await getLogcatTimestamp(); + const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp)); + + const logTask = (async () => { + try { + for await (const rawLine of logcatProcess) { + const line = String(rawLine); + + if (!disposed) { + logBuffer.push(line); + } + + const observedPid = getObservedPid(line, bundleId); + + if (observedPid !== undefined) { + if (state.status === 'running') { + setRunning(observedPid); + } else { + setExitedPid(observedPid); + } + } + + if (state.status === 'running' && isCrashSignal(line, bundleId)) { + setExited('observed-exit', observedPid); + } + } + } catch (error) { + if (!disposed) { + androidAppSessionLogger.debug('Android logcat stream stopped', error); + } + } + })(); + + try { + await startApp(); + } catch (error) { + disposed = true; + stopPolling = true; + emitter.clear(); + await stopSubprocess(logcatProcess); + await Promise.allSettled([logTask]); + throw error; + } + + const pollTask = (async () => { + await sleep(0); + + while (!stopPolling) { + if (isExitedState(state)) { + return; + } + + try { + const pid = await getAppPid(); + + if (pid != null) { + setRunning(pid); + } else if (hasObservedProcess) { + setExited('process-gone'); + return; + } + } catch (error) { + androidAppSessionLogger.debug('Android app session poll failed', error); + } + + await waitForNextPoll(); + } + })(); + + return { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + state = { status: 'disposed', occurredAt: Date.now() }; + + if (exitNotification) { + clearTimeout(exitNotification); + exitNotification = null; + } + + cancelPendingPollDelay(); + + emitter.clear(); + await stopSubprocess(logcatProcess); + await stopApp(); + await Promise.allSettled([logTask, pollTask]); + }, + getState: async () => state, + getLogs: () => logBuffer.getLogs(), + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; +}; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 2b98bf8a..ef79acfe 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -1,10 +1,7 @@ import { AppNotInstalledError, DeviceNotFoundError, - createAppSessionEmitter, type HarnessPlatformInitOptions, - type AppSession, - type AppSessionState, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; @@ -34,70 +31,9 @@ import { } from './environment.js'; import { isInteractive } from '@react-native-harness/tools'; import fs from 'node:fs'; +import { createAndroidAppSession } from './app-session.js'; const androidInstanceLogger = logger.child('android-instance'); -const APP_EXIT_POLL_INTERVAL_MS = 1000; - -const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -const createAndroidAppSession = async ({ - startApp, - stopApp, - isAppRunning, -}: { - startApp: () => Promise; - stopApp: () => Promise; - isAppRunning: () => Promise; -}): Promise => { - const emitter = createAppSessionEmitter(); - let state: AppSessionState = { status: 'running' }; - let disposed = false; - let stopPolling = false; - - await startApp(); - - const pollTask = (async () => { - while (!stopPolling) { - try { - if (!(await isAppRunning())) { - if (!disposed && state.status === 'running') { - state = { - status: 'exited', - occurredAt: Date.now(), - reason: 'process-gone', - }; - emitter.emit({ type: 'app_exited' }); - } - return; - } - } catch (error) { - androidInstanceLogger.debug('Android app session poll failed', error); - } - - await sleep(APP_EXIT_POLL_INTERVAL_MS); - } - })(); - - return { - dispose: async () => { - if (disposed) { - return; - } - - disposed = true; - stopPolling = true; - state = { status: 'disposed', occurredAt: Date.now() }; - emitter.clear(); - await stopApp(); - await pollTask; - }, - getState: async () => state, - getLogs: () => [], - addListener: emitter.addListener, - removeListener: emitter.removeListener, - }; -}; const getHarnessAppPath = (): string => { const appPath = process.env.HARNESS_APP_PATH; @@ -328,7 +264,7 @@ export const getAndroidEmulatorPlatformInstance = async ( await adb.installApp(adbId, installPath); } - await configureAndroidRuntime(adbId, config, harnessConfig); + const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); if (permissionsEnabled) { await adb.grantPermissions(adbId, config.bundleId); @@ -342,6 +278,8 @@ export const getAndroidEmulatorPlatformInstance = async ( config.appLaunchOptions; return await createAndroidAppSession({ + appUid, + bundleId: config.bundleId, startApp: () => adb.startApp( adbId, @@ -350,7 +288,9 @@ export const getAndroidEmulatorPlatformInstance = async ( launchOptions, ), stopApp: () => adb.stopApp(adbId, config.bundleId), - isAppRunning: () => adb.isAppRunning(adbId, config.bundleId), + getAppPid: () => adb.getAppPid(adbId, config.bundleId), + getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId), + startLogcat: (args) => adb.startLogcat(adbId, args), }); }, dispose: async () => { @@ -388,7 +328,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( ); } - await configureAndroidRuntime(adbId, config, harnessConfig); + const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); if (permissionsEnabled) { await adb.grantPermissions(adbId, config.bundleId); @@ -402,6 +342,8 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( config.appLaunchOptions; return await createAndroidAppSession({ + appUid, + bundleId: config.bundleId, startApp: () => adb.startApp( adbId, @@ -410,7 +352,9 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( launchOptions, ), stopApp: () => adb.stopApp(adbId, config.bundleId), - isAppRunning: () => adb.isAppRunning(adbId, config.bundleId), + getAppPid: () => adb.getAppPid(adbId, config.bundleId), + getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId), + startLogcat: (args) => adb.startLogcat(adbId, args), }); }, dispose: async () => { diff --git a/packages/platform-android/test/setup.ts b/packages/platform-android/test/setup.ts index 5ce1e9f0..900ee21b 100644 --- a/packages/platform-android/test/setup.ts +++ b/packages/platform-android/test/setup.ts @@ -57,6 +57,7 @@ vi.mock('../src/adb.js', async (importOriginal) => { waitForEmulatorDisconnect: vi.fn().mockResolvedValue(undefined), waitForBoot: vi.fn().mockResolvedValue('emulator-5554'), isAppRunning: vi.fn().mockResolvedValue(false), + getAppPid: vi.fn().mockResolvedValue(null), getAppUid: vi.fn().mockResolvedValue(0), setHideErrorDialogs: vi.fn().mockResolvedValue(undefined), getLogcatTimestamp: vi.fn().mockResolvedValue('01-01 00:00:00.000'), From b712b52aa67940f49b65b613d2621809b67abfc6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 13:44:38 +0200 Subject: [PATCH 04/17] fix(ios): capture device crash output --- apps/playground/src/app/App.tsx | 173 +++++++++++++++++- .../src/__tests__/launch-options.test.ts | 96 +++++++++- packages/platform-ios/src/xcrun/devicectl.ts | 26 ++- packages/platform-ios/src/xcrun/simctl.ts | 27 ++- 4 files changed, 306 insertions(+), 16 deletions(-) diff --git a/apps/playground/src/app/App.tsx b/apps/playground/src/app/App.tsx index 49335982..e6796da5 100644 --- a/apps/playground/src/app/App.tsx +++ b/apps/playground/src/app/App.tsx @@ -1,17 +1,176 @@ -import { View, Text, StyleSheet } from 'react-native'; +import { + Alert, + Platform, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import PlaygroundCrash from '../native/PlaygroundCrash'; + +type CrashAction = { + description: string; + label: string; + onPress: () => void; +}; + +const iosCrashActions: CrashAction[] = [ + { + label: 'Objective-C Sync', + description: 'Throws NSException inside TurboModule call path', + onPress: () => { + PlaygroundCrash.crashFromObjectiveCSync('App.tsx objective-c sync'); + }, + }, + { + label: 'Objective-C Async', + description: 'Throws NSException on the main queue after return', + onPress: () => { + PlaygroundCrash.crashFromObjectiveCAsync('App.tsx objective-c async'); + }, + }, + { + label: 'Swift Sync', + description: 'Calls fatalError immediately in Swift', + onPress: () => { + PlaygroundCrash.crashFromSwiftSync('App.tsx swift sync'); + }, + }, + { + label: 'Swift Async', + description: 'Calls fatalError on the main queue after return', + onPress: () => { + PlaygroundCrash.crashFromSwiftAsync('App.tsx swift async'); + }, + }, +]; + +const androidCrashActions: CrashAction[] = [ + { + label: 'Kotlin Sync', + description: 'Throws RuntimeException immediately in the TurboModule', + onPress: () => { + PlaygroundCrash.crashFromKotlinSync('App.tsx kotlin sync'); + }, + }, + { + label: 'Kotlin Async', + description: 'Throws RuntimeException on the main thread after return', + onPress: () => { + PlaygroundCrash.crashFromKotlinAsync('App.tsx kotlin async'); + }, + }, +]; + +const unsupportedActions: CrashAction[] = + Platform.OS === 'ios' + ? androidCrashActions.map((action) => ({ + ...action, + onPress: () => { + Alert.alert('Unavailable on iOS', `${action.label} is Android-only.`); + }, + })) + : iosCrashActions.map((action) => ({ + ...action, + onPress: () => { + Alert.alert('Unavailable on Android', `${action.label} is iOS-only.`); + }, + })); + +const activeActions = Platform.OS === 'ios' ? iosCrashActions : androidCrashActions; export const App = () => { return ( - - Hello from the playground! - + + + + Native Crash Playground + + Use these buttons to trigger each native crash path manually on{' '} + {Platform.OS}. + + + + + Available on this device + {activeActions.map((action) => ( + + {action.label} + {action.description} + + ))} + + + + Other platform variants + {unsupportedActions.map((action) => ( + + {action.label} + {action.description} + + ))} + + + ); }; + const styles = StyleSheet.create({ - root: { + safeArea: { flex: 1, - justifyContent: 'center', - alignItems: 'center', + backgroundColor: '#111827', + }, + content: { + padding: 20, + gap: 24, + }, + hero: { + gap: 8, + }, + title: { + color: '#f9fafb', + fontSize: 28, + fontWeight: '700', + }, + subtitle: { + color: '#d1d5db', + fontSize: 15, + lineHeight: 22, + }, + section: { + gap: 12, + }, + sectionTitle: { + color: '#f9fafb', + fontSize: 18, + fontWeight: '600', + }, + button: { + backgroundColor: '#1f2937', + borderColor: '#374151', + borderRadius: 16, + borderWidth: 1, + gap: 6, + padding: 16, + }, + buttonDisabled: { + opacity: 0.7, + }, + buttonLabel: { + color: '#f9fafb', + fontSize: 16, + fontWeight: '600', + }, + buttonDescription: { + color: '#9ca3af', + fontSize: 14, + lineHeight: 20, }, }); diff --git a/packages/platform-ios/src/__tests__/launch-options.test.ts b/packages/platform-ios/src/__tests__/launch-options.test.ts index b5bc63c8..971da54c 100644 --- a/packages/platform-ios/src/__tests__/launch-options.test.ts +++ b/packages/platform-ios/src/__tests__/launch-options.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from 'vitest'; +import * as tools from '@react-native-harness/tools'; +import { describe, expect, it, vi } from 'vitest'; import { getDeviceConnectionHost, getDeviceCtlLaunchArgs, isMatchingDevice, + launchAppProcess as launchDeviceAppProcess, } from '../xcrun/devicectl.js'; -import { getSimctlChildEnvironment } from '../xcrun/simctl.js'; +import { + getSimctlChildEnvironment, + launchAppProcess, + startApp, +} from '../xcrun/simctl.js'; describe('Apple app launch options', () => { it('maps simulator environment to SIMCTL_CHILD variables', () => { @@ -34,6 +40,7 @@ describe('Apple app launch options', () => { 'launch', '--device', 'device-id', + '--terminate-existing', '--environment-variables', '{"FEATURE_X":"1"}', 'com.example.app', @@ -43,6 +50,91 @@ describe('Apple app launch options', () => { ]); }); + it('passes console mode to devicectl process launch streams', () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + + launchDeviceAppProcess('device-id', 'com.example.app', { + arguments: ['--mode=test'], + environment: { + FEATURE_X: '1', + }, + }); + + expect(spawnSpy).toHaveBeenCalledWith( + 'xcrun', + [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + 'device-id', + '--terminate-existing', + '--console', + '--environment-variables', + '{"FEATURE_X":"1"}', + 'com.example.app', + '--', + '--mode=test', + ], + { + stdout: 'pipe', + stderr: 'pipe', + }, + ); + }); + + it('passes terminate-running-process to simctl launch commands', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + + await startApp('sim-udid', 'com.example.app', { + arguments: ['--mode=test'], + }); + + launchAppProcess('sim-udid', 'com.example.app', { + arguments: ['--mode=test'], + }); + + expect(spawnSpy).toHaveBeenNthCalledWith( + 1, + 'xcrun', + [ + 'simctl', + 'launch', + '--terminate-running-process', + 'sim-udid', + 'com.example.app', + '--mode=test', + ], + { + env: {}, + }, + ); + + expect(spawnSpy).toHaveBeenNthCalledWith( + 2, + 'xcrun', + [ + 'simctl', + 'launch', + '--console', + '--terminate-running-process', + 'sim-udid', + 'com.example.app', + '--mode=test', + ], + { + env: {}, + stdout: 'pipe', + stderr: 'pipe', + }, + ); + }); + it('uses the CoreDevice tunnel IP as the direct device connection host', () => { expect( getDeviceConnectionHost({ diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index de938e38..78c67893 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -216,7 +216,13 @@ export const launchAppProcess = ( ): Subprocess => spawn( 'xcrun', - ['devicectl', 'device', ...getDeviceCtlLaunchArgs(identifier, bundleId, options)], + [ + 'devicectl', + 'device', + ...getDeviceCtlLaunchArgs(identifier, bundleId, options, { + console: true, + }), + ], { stdout: 'pipe', stderr: 'pipe', @@ -226,9 +232,23 @@ export const launchAppProcess = ( export const getDeviceCtlLaunchArgs = ( identifier: string, bundleId: string, - options?: AppleAppLaunchOptions + options?: AppleAppLaunchOptions, + flags?: { + console?: boolean; + } ): string[] => { - const args = ['process', 'launch', '--device', identifier]; + const args = [ + 'process', + 'launch', + '--device', + identifier, + '--terminate-existing', + ]; + + if (flags?.console) { + args.push('--console'); + } + const environment = options?.environment; if (environment && Object.keys(environment).length > 0) { diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index 3990e795..5ad7d059 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -285,9 +285,20 @@ export const startApp = async ( const environment = getSimctlChildEnvironment(options); const argumentsList = options?.arguments ?? []; - await spawn('xcrun', ['simctl', 'launch', udid, bundleId, ...argumentsList], { - env: environment, - }); + await spawn( + 'xcrun', + [ + 'simctl', + 'launch', + '--terminate-running-process', + udid, + bundleId, + ...argumentsList, + ], + { + env: environment, + }, + ); }; export const launchAppProcess = ( @@ -300,7 +311,15 @@ export const launchAppProcess = ( return spawn( 'xcrun', - ['simctl', 'launch', '--console', udid, bundleId, ...argumentsList], + [ + 'simctl', + 'launch', + '--console', + '--terminate-running-process', + udid, + bundleId, + ...argumentsList, + ], { env: environment, stdout: 'pipe', From c91b8fd13e2c482db8d751ba71400197722c67de Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 13:55:18 +0200 Subject: [PATCH 05/17] chore: update lockfile --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50b36526..3ed76526 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,9 +486,6 @@ importers: '@react-native-harness/platforms': specifier: workspace:* version: link:../platforms - '@react-native-harness/tools': - specifier: workspace:* - version: link:../tools playwright: specifier: ^1.50.0 version: 1.57.0 From 749ed0fb1f224d5d2198f77961c87dfc10230032 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 14:56:28 +0200 Subject: [PATCH 06/17] chore: add crash version plan --- .nx/version-plans/version-plan-1779972963000.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/version-plan-1779972963000.md diff --git a/.nx/version-plans/version-plan-1779972963000.md b/.nx/version-plans/version-plan-1779972963000.md new file mode 100644 index 00000000..e104351f --- /dev/null +++ b/.nx/version-plans/version-plan-1779972963000.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Crash detection now uses app sessions with native crash evidence, so users get faster, clearer failure reports and more reliable diagnosis on real devices. From 91134e49906d7d509b25522296cca84240d08afd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 15:06:10 +0200 Subject: [PATCH 07/17] ci: split crash validation cases --- .github/workflows/e2e-tests.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b11d2c4d..a558b410 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -272,6 +272,14 @@ jobs: runs-on: ubuntu-22.04 timeout-minutes: 30 if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} + strategy: + fail-fast: false + matrix: + case: + - name: kotlin sync + testNamePattern: kotlin sync + - name: kotlin async + testNamePattern: kotlin async env: HARNESS_DEBUG: true DEBUG: 'Metro:*' @@ -344,7 +352,7 @@ jobs: app: android/app/build/outputs/apk/debug/app-debug.apk runner: android-crash-pre-rn projectRoot: apps/playground - harnessArgs: --testPathPatterns smoke + harnessArgs: '--testPathPatterns crash-test.harness --testNamePattern "${{ matrix.case.testNamePattern }}"' preRunHook: | echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" echo "HARNESS_RUNNER=$HARNESS_RUNNER" @@ -377,7 +385,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: harness-logs-crash-validate-android + name: harness-logs-crash-validate-android-${{ matrix.case.name }} path: apps/playground/.harness/logs if-no-files-found: ignore @@ -386,6 +394,18 @@ jobs: runs-on: macos-latest timeout-minutes: 30 if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} + strategy: + fail-fast: false + matrix: + case: + - name: objc sync + testNamePattern: objc sync + - name: objc async + testNamePattern: objc async + - name: swift sync + testNamePattern: swift sync + - name: swift async + testNamePattern: swift async env: HARNESS_DEBUG: true DEBUG: 'Metro:*' @@ -469,7 +489,7 @@ jobs: app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app runner: ios-crash-pre-rn projectRoot: apps/playground - harnessArgs: --testPathPatterns smoke + harnessArgs: '--testPathPatterns crash-test.harness --testNamePattern "${{ matrix.case.testNamePattern }}"' preRunHook: | echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" echo "HARNESS_RUNNER=$HARNESS_RUNNER" @@ -502,6 +522,6 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: harness-logs-crash-validate-ios + name: harness-logs-crash-validate-ios-${{ matrix.case.name }} path: apps/playground/.harness/logs if-no-files-found: ignore From 55a07123ffb4990e68fddc0491a03df31a8595ed Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 15:59:43 +0200 Subject: [PATCH 08/17] test: split playground crash cases --- .github/workflows/e2e-tests.yml | 28 ++------- apps/playground/jest.harness.config.mjs | 2 +- apps/playground/jest.harness.crash.config.mjs | 10 ++++ .../src/__tests__/crash-test.harness.ts | 56 ------------------ .../crash/android/kotlin-async.harness.ts | 11 ++++ .../crash/android/kotlin-sync.harness.ts | 11 ++++ .../__tests__/crash/ios/objc-async.harness.ts | 11 ++++ .../__tests__/crash/ios/objc-sync.harness.ts | 11 ++++ .../crash/ios/swift-async.harness.ts | 11 ++++ .../__tests__/crash/ios/swift-sync.harness.ts | 11 ++++ .../__tests__/{ => normal}/hooks.harness.ts | 0 .../{ => normal}/jest-throw.harness.ts | 0 .../{ => normal}/mocking/modules.harness.ts | 0 .../{ => normal}/mocking/spies.harness.ts | 0 .../modifiers/describe-only.harness.ts | 0 .../{ => normal}/modifiers/skip.harness.ts | 0 .../modifiers/test-only.harness.ts | 0 .../{ => normal}/modifiers/todo.harness.ts | 0 .../{ => normal}/render-jsx.harness.tsx | 0 .../{ => normal}/setup-files.harness.ts | 0 .../__tests__/{ => normal}/smoke.harness.ts | 2 +- .../android/orange-square-element-only.png | Bin 0 -> 1207 bytes .../android/out-of-bounds.png | Bin 0 -> 4096 bytes .../chromium/orange-square-element-only.png | Bin 0 -> 451 bytes .../chromium/out-of-bounds.png | Bin 0 -> 967 bytes .../ios/orange-square-element-only.png | Bin 0 -> 6006 bytes .../__image_snapshots__/ios/out-of-bounds.png | Bin 0 -> 51545 bytes .../web/orange-square-element-only.png | Bin 0 -> 451 bytes .../__image_snapshots__/web/out-of-bounds.png | Bin 0 -> 967 bytes .../{ => normal}/ui/actions.harness.tsx | 0 .../{ => normal}/ui/out-of-bounds.harness.tsx | 2 +- .../{ => normal}/ui/permissions.harness.tsx | 0 .../{ => normal}/ui/queries.harness.tsx | 0 .../{ => normal}/ui/screenshot.harness.tsx | 0 .../{ => normal}/ui/type.harness.tsx | 0 .../__tests__/{ => normal}/wait.harness.ts | 0 .../android/orange-square-element-only.png | Bin 865 -> 0 bytes .../android/out-of-bounds.png | Bin 3500 -> 0 bytes .../chromium/orange-square-element-only.png | Bin 323 -> 0 bytes .../chromium/out-of-bounds.png | Bin 663 -> 0 bytes .../ios/orange-square-element-only.png | Bin 3355 -> 0 bytes .../__image_snapshots__/ios/out-of-bounds.png | Bin 30118 -> 0 bytes .../web/orange-square-element-only.png | Bin 323 -> 0 bytes .../__image_snapshots__/web/out-of-bounds.png | Bin 663 -> 0 bytes .../src/__tests__/withRnHarness.test.ts | 4 +- 45 files changed, 85 insertions(+), 85 deletions(-) create mode 100644 apps/playground/jest.harness.crash.config.mjs delete mode 100644 apps/playground/src/__tests__/crash-test.harness.ts create mode 100644 apps/playground/src/__tests__/crash/android/kotlin-async.harness.ts create mode 100644 apps/playground/src/__tests__/crash/android/kotlin-sync.harness.ts create mode 100644 apps/playground/src/__tests__/crash/ios/objc-async.harness.ts create mode 100644 apps/playground/src/__tests__/crash/ios/objc-sync.harness.ts create mode 100644 apps/playground/src/__tests__/crash/ios/swift-async.harness.ts create mode 100644 apps/playground/src/__tests__/crash/ios/swift-sync.harness.ts rename apps/playground/src/__tests__/{ => normal}/hooks.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/jest-throw.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/mocking/modules.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/mocking/spies.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/modifiers/describe-only.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/modifiers/skip.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/modifiers/test-only.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/modifiers/todo.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/render-jsx.harness.tsx (100%) rename apps/playground/src/__tests__/{ => normal}/setup-files.harness.ts (100%) rename apps/playground/src/__tests__/{ => normal}/smoke.harness.ts (88%) create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/out-of-bounds.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/orange-square-element-only.png create mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/out-of-bounds.png rename apps/playground/src/__tests__/{ => normal}/ui/actions.harness.tsx (100%) rename apps/playground/src/__tests__/{ => normal}/ui/out-of-bounds.harness.tsx (99%) rename apps/playground/src/__tests__/{ => normal}/ui/permissions.harness.tsx (100%) rename apps/playground/src/__tests__/{ => normal}/ui/queries.harness.tsx (100%) rename apps/playground/src/__tests__/{ => normal}/ui/screenshot.harness.tsx (100%) rename apps/playground/src/__tests__/{ => normal}/ui/type.harness.tsx (100%) rename apps/playground/src/__tests__/{ => normal}/wait.harness.ts (100%) delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/android/out-of-bounds.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/chromium/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/chromium/out-of-bounds.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/ios/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/ios/out-of-bounds.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/web/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/ui/__image_snapshots__/web/out-of-bounds.png diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a558b410..676b4fde 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -272,14 +272,6 @@ jobs: runs-on: ubuntu-22.04 timeout-minutes: 30 if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} - strategy: - fail-fast: false - matrix: - case: - - name: kotlin sync - testNamePattern: kotlin sync - - name: kotlin async - testNamePattern: kotlin async env: HARNESS_DEBUG: true DEBUG: 'Metro:*' @@ -352,7 +344,7 @@ jobs: app: android/app/build/outputs/apk/debug/app-debug.apk runner: android-crash-pre-rn projectRoot: apps/playground - harnessArgs: '--testPathPatterns crash-test.harness --testNamePattern "${{ matrix.case.testNamePattern }}"' + harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/android' preRunHook: | echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" echo "HARNESS_RUNNER=$HARNESS_RUNNER" @@ -385,7 +377,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: harness-logs-crash-validate-android-${{ matrix.case.name }} + name: harness-logs-crash-validate-android path: apps/playground/.harness/logs if-no-files-found: ignore @@ -394,18 +386,6 @@ jobs: runs-on: macos-latest timeout-minutes: 30 if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} - strategy: - fail-fast: false - matrix: - case: - - name: objc sync - testNamePattern: objc sync - - name: objc async - testNamePattern: objc async - - name: swift sync - testNamePattern: swift sync - - name: swift async - testNamePattern: swift async env: HARNESS_DEBUG: true DEBUG: 'Metro:*' @@ -489,7 +469,7 @@ jobs: app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app runner: ios-crash-pre-rn projectRoot: apps/playground - harnessArgs: '--testPathPatterns crash-test.harness --testNamePattern "${{ matrix.case.testNamePattern }}"' + harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/ios' preRunHook: | echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" echo "HARNESS_RUNNER=$HARNESS_RUNNER" @@ -522,6 +502,6 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: harness-logs-crash-validate-ios-${{ matrix.case.name }} + name: harness-logs-crash-validate-ios path: apps/playground/.harness/logs if-no-files-found: ignore diff --git a/apps/playground/jest.harness.config.mjs b/apps/playground/jest.harness.config.mjs index 10d71009..bc800223 100644 --- a/apps/playground/jest.harness.config.mjs +++ b/apps/playground/jest.harness.config.mjs @@ -1,6 +1,6 @@ export default { preset: 'react-native-harness', - testMatch: ['/**/__tests__/**/*.harness.[jt]s?(x)'], + testMatch: ['/**/__tests__/normal/**/*.harness.[jt]s?(x)'], setupFiles: ['./src/setupFile.ts'], setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'], // This is necessary to prevent Jest from transforming the workspace packages. diff --git a/apps/playground/jest.harness.crash.config.mjs b/apps/playground/jest.harness.crash.config.mjs new file mode 100644 index 00000000..48155f02 --- /dev/null +++ b/apps/playground/jest.harness.crash.config.mjs @@ -0,0 +1,10 @@ +export default { + preset: 'react-native-harness', + testMatch: ['/**/__tests__/crash/**/*.harness.[jt]s?(x)'], + setupFiles: ['./src/setupFile.ts'], + setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'], + // This is necessary to prevent Jest from transforming the workspace packages. + // Not needed in users projects, as they will have the packages installed in their node_modules. + transformIgnorePatterns: ['/packages/', '/node_modules/'], + collectCoverageFrom: ['./src/**/*.(ts|tsx)'], +}; diff --git a/apps/playground/src/__tests__/crash-test.harness.ts b/apps/playground/src/__tests__/crash-test.harness.ts deleted file mode 100644 index 981045a1..00000000 --- a/apps/playground/src/__tests__/crash-test.harness.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Platform } from 'react-native'; -import { describe, it } from 'react-native-harness'; - -import PlaygroundCrash from '../native/PlaygroundCrash'; - -if (Platform.OS === 'ios') { - describe('iOS crashes', () => { - it('objc sync', () => { - console.log('before objc sync'); - PlaygroundCrash.crashFromObjectiveCSync( - 'crash-test.harness.ts objc sync', - ); - alert('after objc sync'); - }); - - it('objc async', () => { - console.log('before objc async'); - PlaygroundCrash.crashFromObjectiveCAsync( - 'crash-test.harness.ts objc async', - ); - alert('after objc async'); - }); - - it('swift sync', () => { - console.log('before swift sync'); - PlaygroundCrash.crashFromSwiftSync('crash-test.harness.ts swift sync'); - alert('after swift sync'); - }); - - it('swift async', () => { - console.log('before swift async'); - PlaygroundCrash.crashFromSwiftAsync( - 'crash-test.harness.ts swift async', - ); - alert('after swift async'); - }); - }); -} - -if (Platform.OS === 'android') { - describe('Android crashes', () => { - it('kotlin sync', () => { - console.log('before kotlin sync'); - PlaygroundCrash.crashFromKotlinSync('crash-test.harness.ts kotlin sync'); - alert('after kotlin sync'); - }); - - it('kotlin async', () => { - console.log('before kotlin async'); - PlaygroundCrash.crashFromKotlinAsync( - 'crash-test.harness.ts kotlin async', - ); - alert('after kotlin async'); - }); - }); -} diff --git a/apps/playground/src/__tests__/crash/android/kotlin-async.harness.ts b/apps/playground/src/__tests__/crash/android/kotlin-async.harness.ts new file mode 100644 index 00000000..1376fd59 --- /dev/null +++ b/apps/playground/src/__tests__/crash/android/kotlin-async.harness.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../../../native/PlaygroundCrash'; + +describe('Android crashes', () => { + it('kotlin async', () => { + console.log('before kotlin async'); + PlaygroundCrash.crashFromKotlinAsync('crash/android/kotlin-async.harness.ts kotlin async'); + alert('after kotlin async'); + }); +}); diff --git a/apps/playground/src/__tests__/crash/android/kotlin-sync.harness.ts b/apps/playground/src/__tests__/crash/android/kotlin-sync.harness.ts new file mode 100644 index 00000000..f8cc3092 --- /dev/null +++ b/apps/playground/src/__tests__/crash/android/kotlin-sync.harness.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../../../native/PlaygroundCrash'; + +describe('Android crashes', () => { + it('kotlin sync', () => { + console.log('before kotlin sync'); + PlaygroundCrash.crashFromKotlinSync('crash/android/kotlin-sync.harness.ts kotlin sync'); + alert('after kotlin sync'); + }); +}); diff --git a/apps/playground/src/__tests__/crash/ios/objc-async.harness.ts b/apps/playground/src/__tests__/crash/ios/objc-async.harness.ts new file mode 100644 index 00000000..2dc37a1d --- /dev/null +++ b/apps/playground/src/__tests__/crash/ios/objc-async.harness.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../../../native/PlaygroundCrash'; + +describe('iOS crashes', () => { + it('objc async', () => { + console.log('before objc async'); + PlaygroundCrash.crashFromObjectiveCAsync('crash/ios/objc-async.harness.ts objc async'); + alert('after objc async'); + }); +}); diff --git a/apps/playground/src/__tests__/crash/ios/objc-sync.harness.ts b/apps/playground/src/__tests__/crash/ios/objc-sync.harness.ts new file mode 100644 index 00000000..ae984316 --- /dev/null +++ b/apps/playground/src/__tests__/crash/ios/objc-sync.harness.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../../../native/PlaygroundCrash'; + +describe('iOS crashes', () => { + it('objc sync', () => { + console.log('before objc sync'); + PlaygroundCrash.crashFromObjectiveCSync('crash/ios/objc-sync.harness.ts objc sync'); + alert('after objc sync'); + }); +}); diff --git a/apps/playground/src/__tests__/crash/ios/swift-async.harness.ts b/apps/playground/src/__tests__/crash/ios/swift-async.harness.ts new file mode 100644 index 00000000..b4eaba03 --- /dev/null +++ b/apps/playground/src/__tests__/crash/ios/swift-async.harness.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../../../native/PlaygroundCrash'; + +describe('iOS crashes', () => { + it('swift async', () => { + console.log('before swift async'); + PlaygroundCrash.crashFromSwiftAsync('crash/ios/swift-async.harness.ts swift async'); + alert('after swift async'); + }); +}); diff --git a/apps/playground/src/__tests__/crash/ios/swift-sync.harness.ts b/apps/playground/src/__tests__/crash/ios/swift-sync.harness.ts new file mode 100644 index 00000000..5ed9835b --- /dev/null +++ b/apps/playground/src/__tests__/crash/ios/swift-sync.harness.ts @@ -0,0 +1,11 @@ +import { describe, it } from 'react-native-harness'; + +import PlaygroundCrash from '../../../native/PlaygroundCrash'; + +describe('iOS crashes', () => { + it('swift sync', () => { + console.log('before swift sync'); + PlaygroundCrash.crashFromSwiftSync('crash/ios/swift-sync.harness.ts swift sync'); + alert('after swift sync'); + }); +}); diff --git a/apps/playground/src/__tests__/hooks.harness.ts b/apps/playground/src/__tests__/normal/hooks.harness.ts similarity index 100% rename from apps/playground/src/__tests__/hooks.harness.ts rename to apps/playground/src/__tests__/normal/hooks.harness.ts diff --git a/apps/playground/src/__tests__/jest-throw.harness.ts b/apps/playground/src/__tests__/normal/jest-throw.harness.ts similarity index 100% rename from apps/playground/src/__tests__/jest-throw.harness.ts rename to apps/playground/src/__tests__/normal/jest-throw.harness.ts diff --git a/apps/playground/src/__tests__/mocking/modules.harness.ts b/apps/playground/src/__tests__/normal/mocking/modules.harness.ts similarity index 100% rename from apps/playground/src/__tests__/mocking/modules.harness.ts rename to apps/playground/src/__tests__/normal/mocking/modules.harness.ts diff --git a/apps/playground/src/__tests__/mocking/spies.harness.ts b/apps/playground/src/__tests__/normal/mocking/spies.harness.ts similarity index 100% rename from apps/playground/src/__tests__/mocking/spies.harness.ts rename to apps/playground/src/__tests__/normal/mocking/spies.harness.ts diff --git a/apps/playground/src/__tests__/modifiers/describe-only.harness.ts b/apps/playground/src/__tests__/normal/modifiers/describe-only.harness.ts similarity index 100% rename from apps/playground/src/__tests__/modifiers/describe-only.harness.ts rename to apps/playground/src/__tests__/normal/modifiers/describe-only.harness.ts diff --git a/apps/playground/src/__tests__/modifiers/skip.harness.ts b/apps/playground/src/__tests__/normal/modifiers/skip.harness.ts similarity index 100% rename from apps/playground/src/__tests__/modifiers/skip.harness.ts rename to apps/playground/src/__tests__/normal/modifiers/skip.harness.ts diff --git a/apps/playground/src/__tests__/modifiers/test-only.harness.ts b/apps/playground/src/__tests__/normal/modifiers/test-only.harness.ts similarity index 100% rename from apps/playground/src/__tests__/modifiers/test-only.harness.ts rename to apps/playground/src/__tests__/normal/modifiers/test-only.harness.ts diff --git a/apps/playground/src/__tests__/modifiers/todo.harness.ts b/apps/playground/src/__tests__/normal/modifiers/todo.harness.ts similarity index 100% rename from apps/playground/src/__tests__/modifiers/todo.harness.ts rename to apps/playground/src/__tests__/normal/modifiers/todo.harness.ts diff --git a/apps/playground/src/__tests__/render-jsx.harness.tsx b/apps/playground/src/__tests__/normal/render-jsx.harness.tsx similarity index 100% rename from apps/playground/src/__tests__/render-jsx.harness.tsx rename to apps/playground/src/__tests__/normal/render-jsx.harness.tsx diff --git a/apps/playground/src/__tests__/setup-files.harness.ts b/apps/playground/src/__tests__/normal/setup-files.harness.ts similarity index 100% rename from apps/playground/src/__tests__/setup-files.harness.ts rename to apps/playground/src/__tests__/normal/setup-files.harness.ts diff --git a/apps/playground/src/__tests__/smoke.harness.ts b/apps/playground/src/__tests__/normal/smoke.harness.ts similarity index 88% rename from apps/playground/src/__tests__/smoke.harness.ts rename to apps/playground/src/__tests__/normal/smoke.harness.ts index 393fb879..af8fe99c 100644 --- a/apps/playground/src/__tests__/smoke.harness.ts +++ b/apps/playground/src/__tests__/normal/smoke.harness.ts @@ -9,7 +9,7 @@ describe('Smoke test', () => { expect(context).toBeDefined(); expect(context.task.type).toBe('test'); expect(context.task.mode).toBe('run'); - expect(context.task.file.name).toBe('src/__tests__/smoke.harness.ts'); + expect(context.task.file.name).toBe('src/__tests__/normal/smoke.harness.ts'); expect(context.task.suite.name).toBe('Smoke test'); expect(context.task.name).toBe('should expose task context to tests'); }); diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png new file mode 100644 index 0000000000000000000000000000000000000000..4902ce621133112ef192479572b4622e58ff5606 GIT binary patch literal 1207 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Ca1v8whg%Nrw0P_nKPU02z$MLGDffR=lk!11og0XOh&>8OfU>(Ku(VF?Tho)hOD6~a3A~Fe4P}NmLR?EdZ0D}n1QJ%UG z?7)XqncA_&^`)QwBjKIRlJSSX3I6m}CG`PF^Tp+-(+5snfIYJUscQ>A11`-YC%RyJ zr5pAf=m0tW%+c%sv$6?)O#ARH09FHBx1V0#qp5hxehcia*Ju3-W+rF9-#)VG0jl96 z$~WEzn2Pm0SAZ>&lmaOQQVOILNGZS!*y|JQ4x?>2Hg}A)gP}X4rV}{x2S^D~ ArvLx| literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png new file mode 100644 index 0000000000000000000000000000000000000000..861e1bd170d6523969492e21841b808f664ba5b1 GIT binary patch literal 451 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Rgs*VGbrB`F{UiAYx*MFg;xyLnRE@B-~!Vlqy0x6o{9JpnL5SA2>iU?DnF$7v-dTk%XMjdSd rk|0nQBP^gHCTIaEV1ToMf}0r{1*~_#0S=L2@O1Ta0WzHufCLu+a!bi> literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..50189eb87417a69d772362521144e33d587ce997 GIT binary patch literal 967 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Ca4A`~7=?yc7;5APHng12I^L$;Z>hF{A>*1(J@r z&Vd3f5N?VXgw6(1ia;!GzzHNB41h#FS9OVZs6-l&2}JP_3P|!qAKwJ$m_b+;Z~-7g z5{T`P1t9E12vxyj4P<~gc3@`$8Nxu!z#yOiq!<|#9Do!v1BfhO2l)ep`4|`&fs`x* z0~3(4W?*39C}&{c5CBP!Y8(w5U^*C(lmoPv6_$z>>kYxlk%0}A-IzhiR*w%zz>yR% RUov>Q`ndo_oDzTp7XX^RpFjWr literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png new file mode 100644 index 0000000000000000000000000000000000000000..661b830ca0b093cb75c0349d85d757d34a692219 GIT binary patch literal 6006 zcmeHL%}*0i5P#ccOKDlHXn+)mNFqU9upl)gfV5ZwrF^9jSO|qefLN-D388>N>dimF z#EYIy^uW!Sh6Lqeym(MAUOn?K_?w-_7D{g#y=*(*^LF0b**DX^JwC`!j1B7EfL92i z$A+Q_ArynqG45+6N0jl80;5N0QEqaKv&J!+UTgb3C$xkrAnwY6knkPgl5S? z3@dfEoKRZi7?%|wEz0DmIdJ-LY(%gn6(*$+8Jg?lD#d`N7Bm@PmV=0j$3wFlLR{-2 zR0d#ePhm)XIyycDjwM(h9^C?&C5yU2bKBV`LXZ@TMy9qE2!P1C8u8!%`W|BME=UEN zL4zzUG&RCRUQBM0zyM?b7hJ>%He*n^f*lUtdEk1{ zDBDl%5YSx|n5{=|!HYc3l>D5K87HxMXZz%xB01NC?C#mNSq?UHYkF>UnH0S zC$`hmSZ4*H&Cr3zwx^@*el7Cr)pOf&$Ce;W$Rowl&o>RWeRr>I%T`#>x0$;e%;Yje zIvN4wf1AQG=zmHL`#|psBSn94=zyaOpUIA%8F1H$7G5K-s~V&O`0e$H)&&6|W7GKv zun}ruK{GC_SmAF$pVz(4L{92binAI1ypDR6I2EdV>A=-i&dd`x1CYW%CDjW`v)Mog zo;j@*splM-Jw;%`5{pAB_3aA;WbR#$w_otU2fFpW)VXk*;XM&DUz^Y6$j|tJm zfB_HcJx(tAxupZr2(S&vkN~^EqGsqT0h-68#`pal^&`OT{*>ag?xs`FOM|uLP3d4Z z23Bg7S!#J(xl4uq1zn+Kg)V~tR&&g=)RWGw5xxxjnffW$E}*+Z7jd?kNm`K&lf^5A zCrS4XSY&3k&$9`KgHpdJLzP-DEhHdcd^MGoO#8HK=JF|CDYe?nk7qlGRC5gka5mvE z&^0yjN9--J(!$oDgKw#%J6prq8vkf(V490v3!uRV^kWjCx6HADu_)wzs&emdj`SAI literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..5bfee10ed6eaf23b35a5f5f80c99e790836067b8 GIT binary patch literal 51545 zcmeHQ>u(g-72ol~g3X#32aIiKSR7~)$Aoq4V8;otah+hGahwFqtH7EVL#Q#P!F9kU zI7(Zot<-*LKUHm|d~8+a^&yqgC{iA6)h12Ts){O7(hrsTt^F7J``vTr&dlz5U$8Ti zYV32LJC8HxKF+!4o_p_q-+6mc8*29gA6AD-+0j8VSHD zM6KtZ+A|oF^hR|J6Q>XFoqYW9SSK!68Cz~O$Kt#c|1qmQwp_(yv3=GC6MxFOTVGQb z;{%?JEz?&WMaVySHlAQ&ab#J}laZ>k?;^H(2;fIS>qCvm?p_9lslSrmV({hFJ+nA9tNnYoXF)2zk6)o-3Zn`tsb{&!!VID@Z2dAZJJt@Mm;%8N_XB)d}cwg^l1Gt zM0FF6Id7HwrqjBJhb5i(x`Ybbty4&htl1IQA0>F2!j&uG4}63&U3>TsKY8pjQV(7p zF~3NJb@LSIe;=GvrL0e0J&O!0(U}-}@8xk-MN}bNxUi0ou;PvJx;aGVH0{nF?nSCF z?N0E*Sw0V7@iVW*`QbBgs%`N78|vpbA(()(;P#z5c9EZUEw0H%T-8QJgKg6;3LKO>;Crbc*~EZVhxh`B zGT?<}62c$YDNz^we*Z0GP1Ak$Dtr{iwZAx(2|3AhQ+KMprBTro>5Nm#`aQ_(A|dtX zT;!*&J_HZI#v+hxPu$N+cITj^Tl+6aM7QG0sCy~on7WU+>s>36WRVFxBEfkCr{Q{< zRHcyQT-y)F7oI*9f>f-sxsSU&C zH&z&FWw|%nZ9dhy>ki!w;M%=ub1Nde2Ec|{hmfTRcveUwiKh_giXj<^Aqm%zAT~~} z0+nAC=W<^l+1KA{V~^q~6a3QqJ!1%`djb>T%E1y;&r?GU@?npo=Z6RaW zWZts&jwkY255t5^*J2|eoJC>rkKIPR$&EXFs}XzDHm(&WMNb00D$q_E-~@9 zv2!pDV>{bQb2{z_8QW%b?KL;CUI;wcjSxc|3IwcXF*snxDqVMJ&8~QI!T-(y?*^>d zJU_IT4b=Kgq*=kn+MbgZRyT;W~aGdA~N13HZ`xYjr;r0lLWm~EC6QH*?B?ryBsbGD;{p0VGqA+HKiY$ zsoGCCggj_6;MZ~}P;97$R|Afcpq+3KNQK6ldP)+rvAqu$XlzqtRXI>dXTZ9uL>kR( zR=oEza%P*86D*f=EY37s22LP5Mz(c-N>ZBB6&cWuQxBBs@7m~82OC{~i0hFHi;D&L zri6S~1If#3@z*!tO=mvy(30|-T*n{bzB%6q`5qCEUZH?mL+(2J0>WkNm)Ks99@s|B zT1xjjx|G{XR$JV92b=(Vq~s;`djPio+71p_{2m1W(&yj+&yL)mV z3HSo={9J`azGg)}1-?gQHMtTw*^4mqJ5>u1yVt1)shK%;Q$xBrsvZOVPC-2~D;3}Z z+~8DCKl`|aPhOKu_F0}`ypaO&Ly0Ooy`6oNJ*q^smTCb!eg&KX3SSGqC1ngOt#aWf z3$XP9q%oyT_CCav45&G${BYBn0z8KL1O%(#)Tk3-QSDVA0>KBQ<5f5Sdo712hEy03 zRO{wM!Y*?hOD4gBcKVN5KNJPz90qpYOU|@G(xr%ttFENw()9>98PM%Vot0PS2#2MJ zEH@cH5G@zpt~lrr`Dgfq1NK>f9bNFG2STJoCo8x3FBfF+Vz(ZnI5h=Jdv=g)p{7Gt zCA947nE3d6^5U`;+U$yLz2BMHf3VxDt$0OB6q(P;(Z2?z0`LfdK)FPn^p){bO9%Lx z1RQvZ0$$Sa!^ifO@{2k`fL4)|9IN6|r5ET}P&`e`b+weJ3Qr7|E2*+Cqo}|saRh+c zPXqQ-(ZjLmN&QMS!C#WJ`r-i806^8ABd5L{NkAyMe&uso@-fB_gx^!Hx5pn#D^?AT zS2s5IBcUf@Yp1ZDkT>p;08_(=ke{BDi~>rPE%-&v8FKvL*i{{u8&s!<=q=Q$%7eux zdX-IJ`KJ0OCC4~sYl5@F%jQ|SfP-~`GONNX3QYDE zAAJU-=%-?*bI8`ruzw8^gST}Jhk%yo8p5XB2V#Kba59U8NU3q>%;&%Q^N-~|V^u53 zha`Y+&-x-dY%(b*-V)PE^F*U3FO#h1#;nA)w^K*uu+(tQO8Gj{uiEm@f3{zTvO9km zp2sjLe0*Oi_Pv@2sebU65~Vl<5l}PPCbbW-n$3}19s2#x)M|^yQYI)AM(6Imv=sqG z1Axsn??XJ478AfL5IueYk*{bK1)hGec+BeJP1NJqeRcTQyIrS%BsClD2#Xp_8#4)+ ziN)n@wUUtXvXnXaWFxidIr(s6ORF`2AaEY(pNT zYOh>YW}0{tO^g2dn->u)r5g#>Xg6Q&5a!{$%Lq1<48D2`2vijRnR}HpOyB4OwrWkG zQc=`)>N9VWHq55Gpi91a5i&zIss~11tphULu=HFgt{R7Pt$vRhdxaKztjU1zT0#hZ#`o1`H(v-W zMPNS$_&Kw-VdndsEWG}z6rP!8M>0&Bd|G`Al(PV;Y4s{xtQT7QwdR3Bi2-fn%4w}K zR+k23m20rA-1@=X8jvdAD*+alPz|m@2n1>l_c^mojKKww5DEjFBT~jhMF*&Q>8^Z> zYGpks^I=r})qE)_-ci=_C&9`$n_RrkYw22)jJP@|MScOTc)1dkkkt78v3e!?RW{!Q zEdAj1LX71IWEQtf-RZlF*MEt7E^>+%Eymu%qxNG`ysvq+hnVcwqtiB0(d>V}C)=QC1Y6F_o)*WBn9C%iTdD5UyX18N}D@YJ!ium>AHD z2*NlI<-3h;&Te919^dEN6oru0Qwag6W{DGS^PSOiykl%#5ohyXT3>=60axJ$$!{r> z8rTpz8;RgdazT`Qc@LT8P#2d4F}`H>ohG5PBDjR4sok}|ciWwn~XRV-~D z*VcA%16}|y#;%BeA#8N>_2Qu);tQo62~X0cL{i^*f5kf+1-T)Y(9pI)Dxr|Z#Y1qN znf375#sFtRyyVG$J6)kkh5JJE$z4H=^YLb{u3S(t{?gVG!HEHtPe_^12|@(Oo!>3D zo23ed;-CS4cIV8qNh)cT;-g_NXkm{zUHe>yYNYQmlLfvZ5(Y-4D(#k&L6Eo|Zr`0a z5M+=Cg2=DNkB$SuqRQwv5RXBeGl}6D;YP=S*xM1J^|*FOcD77Z^Sse#m9 z$1GfQ=%>cf9REeffkelFI4Aj_LU08SYd9-n%45|3OPOkc($x`^$7B7 z?N#KtLT4$PH?CZyf>NFJGbdO^$ALu0fvC!S)0=!&zIBow5=%u4@y1D7qz0Y?aM5uf zAVPE;h~_^2SKIR0lr$_>S?b7a=4nO8fg}xOmOLBGTw!$nWm|N4PED-csu*BcKusce zmsFR_WfYwN(QzPZ+A#kBn{tgggp9_Ijssz>@}3M>*(OVY?EY=aFGsU4`9%h_(QzQ| z6Jmv2(QzQIEy_b>53emxqT@i!5v{5N4MueClXYw~G;G&)Z93viyO20L6kXshKukSA#5n;C z309n2aVjVt6CDSl8e47nJ)3u#qUGaE^`xA<29^q~6yJ#PgT#OBI11Hny)!lMVwh&%T`D~h2d3l0#U#o32@kGbR39?#zGMIHiLAnRG=35R+~YX z_p!79zMk-9%XgK`CHczDg(mhCt)xnsmdRD(N5_G@^L8<#-F&q}n1}POonu0|O=$HN z5U8#GGxsWIn4;rAmQskI6sy>b$@ft`(DZ5@q^~so(i>dhl5x1`IFQ8s1$6h(aUfn( zj7)D=J9ozy(6R;+*~7i?N6atD1KSX95zS$`Z>|P@X2eE*@i$Lke)9DmLjkp1ALB9$2P;`B#xB` z^LC*e7e>c{fCvWc!80Rbn|P&x=s1wGt*rS5tVD;) zJi}xoQZ=#5J%--AERemz@b(MQaUiNDVD6mq_pB61Ted0*OO2xOgy=XB3JFgOEtMjb zv{hppJ$td@y_bimxGb*^dL3A95G%)e0o6)*b&%SdWJI$i3o=ONnbR0+r#rW%Q2|3{V zZzB=hKH&J=e0dL<9I|jPLqKcRY)-v`tl)ft>`Wkt@x5zO7f3|Mfs8m?d5T5PXm_f0 z*BwqF6`Rj5Z!J@F9Ehp$#fX`&7Z3dqU%tzc@FZPIB#n*(Q61nC%N&G;whhu*B#WF+ zjbDf0qT@h71a?{Zdzl~;kYfT{f~q0de8bU|%Lf>LX={n##DK~tq|E08Ap%sp(GgWJ zRKFOAjssajHBo%&lhDt%k$$BcMfkX2XH-bBn`?*`ShcoG295`b?R|8FUHc%_b`Adn D%|b9I literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/orange-square-element-only.png new file mode 100644 index 0000000000000000000000000000000000000000..861e1bd170d6523969492e21841b808f664ba5b1 GIT binary patch literal 451 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Rgs*VGbrB`F{UiAYx*MFg;xyLnRE@B-~!Vlqy0x6o{9JpnL5SA2>iU?DnF$7v-dTk%XMjdSd rk|0nQBP^gHCTIaEV1ToMf}0r{1*~_#0S=L2@O1Ta0WzHufCLu+a!bi> literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/out-of-bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..50189eb87417a69d772362521144e33d587ce997 GIT binary patch literal 967 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Ca4A`~7=?yc7;5APHng12I^L$;Z>hF{A>*1(J@r z&Vd3f5N?VXgw6(1ia;!GzzHNB41h#FS9OVZs6-l&2}JP_3P|!qAKwJ$m_b+;Z~-7g z5{T`P1t9E12vxyj4P<~gc3@`$8Nxu!z#yOiq!<|#9Do!v1BfhO2l)ep`4|`&fs`x* z0~3(4W?*39C}&{c5CBP!Y8(w5U^*C(lmoPv6_$z>>kYxlk%0}A-IzhiR*w%zz>yR% RUov>Q`ndo_oDzTp7XX^RpFjWr literal 0 HcmV?d00001 diff --git a/apps/playground/src/__tests__/ui/actions.harness.tsx b/apps/playground/src/__tests__/normal/ui/actions.harness.tsx similarity index 100% rename from apps/playground/src/__tests__/ui/actions.harness.tsx rename to apps/playground/src/__tests__/normal/ui/actions.harness.tsx diff --git a/apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx b/apps/playground/src/__tests__/normal/ui/out-of-bounds.harness.tsx similarity index 99% rename from apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx rename to apps/playground/src/__tests__/normal/ui/out-of-bounds.harness.tsx index d6afb83d..3cb34298 100644 --- a/apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx +++ b/apps/playground/src/__tests__/normal/ui/out-of-bounds.harness.tsx @@ -22,4 +22,4 @@ describe('Out of bounds', () => { name: 'out-of-bounds', }); }); -}); \ No newline at end of file +}); diff --git a/apps/playground/src/__tests__/ui/permissions.harness.tsx b/apps/playground/src/__tests__/normal/ui/permissions.harness.tsx similarity index 100% rename from apps/playground/src/__tests__/ui/permissions.harness.tsx rename to apps/playground/src/__tests__/normal/ui/permissions.harness.tsx diff --git a/apps/playground/src/__tests__/ui/queries.harness.tsx b/apps/playground/src/__tests__/normal/ui/queries.harness.tsx similarity index 100% rename from apps/playground/src/__tests__/ui/queries.harness.tsx rename to apps/playground/src/__tests__/normal/ui/queries.harness.tsx diff --git a/apps/playground/src/__tests__/ui/screenshot.harness.tsx b/apps/playground/src/__tests__/normal/ui/screenshot.harness.tsx similarity index 100% rename from apps/playground/src/__tests__/ui/screenshot.harness.tsx rename to apps/playground/src/__tests__/normal/ui/screenshot.harness.tsx diff --git a/apps/playground/src/__tests__/ui/type.harness.tsx b/apps/playground/src/__tests__/normal/ui/type.harness.tsx similarity index 100% rename from apps/playground/src/__tests__/ui/type.harness.tsx rename to apps/playground/src/__tests__/normal/ui/type.harness.tsx diff --git a/apps/playground/src/__tests__/wait.harness.ts b/apps/playground/src/__tests__/normal/wait.harness.ts similarity index 100% rename from apps/playground/src/__tests__/wait.harness.ts rename to apps/playground/src/__tests__/normal/wait.harness.ts diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/android/orange-square-element-only.png deleted file mode 100644 index 98f46435d3de6781f1ae02648ab02924e292de63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 865 zcmeAS@N?(olHy`uVBq!ia0y~yU}OVf4j{?UZfb1VJUX< z4B-HR8jh3>pr$ZS7srr_Id5+n2C_JcxL%aYdBBjA6tiaTOw-M26B=gR6XEdOm4DTe zEAGeYf40vgiW&Zx9JhG4+MVI{&N(~>@{8qGt20RNlE40n^FZ3U%HEF=dzo&Wdwu;C z>w!Ew`(uy)=6>Z$FuuS2Hdy|%RQq?9564fxVgxz`3jQlLvB202lUydi*$$I9gy0+x zAx9=S$5Y7z&Q|bb;e>NkIu+n1H>e1zz$GTAj9LN?jM3nNriszi1r3PNd;%*aMvE0_ zNQ{=k(0~}NPoM!YTKz%;VzeOv4T#Z(1T-K<8xqif7;Q*E1A@WRUt@s~_l-ZFuYp87 MUHx3vIVCg!025F&%K!iX diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/orange-square-element-only.png deleted file mode 100644 index 64c61141ca118bff5c3297938204bb56da77ac8a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 323 zcmeAS@N?(olHy`uVBq!ia0vp^DImxdHmX`P}SB*2{BuV)GuyqB6C5vD+6xWV+=zVk0;>Szm) r1b;9BL*V~|r&{}fVZa6o17?O97g+CXgTe~DWM4fGrd}x diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/chromium/out-of-bounds.png deleted file mode 100644 index f2af63da6ec4d0106fb43101afdb26dfbb23a59f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 663 zcmeAS@N?(olHy`uVBq!ia0y~yV15B)r*JR<$yw38y$lRYKAtX)Ar*7pTyxBI4isQH zxHU!W;nVDQiW9{RIA=N-%*f}eF7XbPNL#Wm{_wT8JkiHDZCm%+?9CsGhqu1Jmz-wz zuYKFr;B4q1>>9Rj#&tGbA={Weejz4U$*Vfg5pX6G(p4 z2vBg2h6N;DaIRTz&--UVW6QzEk%zn(elV`K3u3N&Rrf!5Ixw*qE|d0n(R`gv zHX=6ZtNhe{-aR)MHZ*)?c_hB^*IY9Oodm{i=9#<<+z|&(*@QPU0qI8$Y0L+h#5Ne6 zzq$ZKTTHkK)WN#uOtGm3kk;W#0c+F#9K*{ss(qveoh_qx#P!6R|2M8?fB%%jJ3{J$ z!&1fs+aF>p{(L;kxhe{ngr?QUnoP)Kh)6nnBx*O?nce#n8d-G`G8HZ}Y&bHj^Xkz} z`y;ZfS;aOqEN%2*J|L8>cI}pF!o9z)9KclN%{-H>LG_x?8ogR0fu#7RG(^PnM zcWV8{D`lWm3pamj_JKXWy9FYE&Sf*=W$<1zGa^>!clXk5!N}&Xjb(mzH@*dw9bjI1 zm2@*+XX{*K^RwSd8NC0e#De6dF!9s>l2*PIK{h}8dPC)RaYbY=?TYv&?wz|F*?dTl laUgqXG{}%r0S$ueCBH@73WE&o!;YZdf~TvW%Q~loCIBF=JW~Jw diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/ios/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/ios/out-of-bounds.png deleted file mode 100644 index 33126c3118365b340e1cb5f9468d825ff55554e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30118 zcmeHQYgiLk8crA-0}Ki%NWj)eb=%@C6f2ZgjV#nlTZ&duplYBBR58X?iinq_qM~do zy=b+9BKz#>3JB@~QA7i3wXK3~7mX!g(6(3+utmAl0()kXnMvEz#3woPZ}h<+bT} z8A}(;OnBoBfj{_OBp4v{5Mbm}z+ZvTM=*f;ULcqy^ku%EBYe*E90!45+q(is({p0L zBl$-MzQC(Zk650NeaAcp`q?g;JdFNcIF@`_wA1`L@ZhAJ5xrU<7#U1{3Bz{>$iWYX zzZ>@ET<{1!kovP5{QDgEA|Jt5NZPjx3s->0@R?!L=B^dq*H;yN|Ekx3s+DiX&P@Dv zaO={cDWa2k_@|m1kPi|=LN$U;7aPJx3B}GHaJePm> zsamY-7e6F_Er~Pqq%G>dtPUZ+tMR%Cj^2I z%5y7?`A7HXLVOB#yEC`gIj;D{brD47I&5}vzo2SUMwRv~?mWBT#T~_Bxgk)Z7}g!A zm0gheEnB!IE;m6dz?{bp_}uqb@g;9JP5Z9iZg8Qah_LFYSb2iW(5%OA^afzJ+nPLr z_6ad3tU9hqiV<^(%!&bRgfu%q^1!PHyxn<~azXHA0p^MADya(z*AY^QPv6JUVEpO~ z_0*_Y&;4uYTZHt%2;UiDMLWB7tK(JbcDG~~DVV(^Z=L^;xPlaL&C*Dx*rK;Gt_ZNb zC3UTJ3TdKBJwta7UtYcke8%YH(g^oS7?HU({G4f4x{lopxdNtR=z_2Iy_$CCrL>4! zRp0+a&40YiF#c!o`b^=qlB7LC+#e4+mmi0F9|ogbxzaNNjK&pnNG(sLu0CnO#NS>0 zqST*LlE`)F--N{;R9>gnRHpNFN*~d(WS6jhR($L#qH`1G-F<3NkOsUP5k}0dj%pbi z7pPTfVhuF{OpJX5hS8vCPf)36>6YdCCF9<1u31~f^1OJNZkz&K-QR`twjJNbUfm_{ zlG3?lUM))=offJeZ*=@fb0cJOK?`&3mokbJI)%QZ6%2LLe^uIs)&@fA79jEQa-|lP zo}(qz*Bizr6(wl3tFY#*LGugSd`}+#lJLP_h--N!w6oAVcxB#CF{Qm}VA3URUghA$ zUoaCa(x+bP`()bD{uLcd4QB)0oMIaqTRRnC?lOw5KVO}oVy4vb+{Mjt{Y}~sC;WxL z$kM|xz%Jqq>!o9IHi_M862WHmkIhOszOGUG$oF7xZ@QD{^c6|GY~olSFeN*LtGa52 zIV=g3=f%kkZR7`JGV_z?Q5JJZv`+{wxImP4>-ZL3f>N*T$$w{pFc9n-av%k63p2qi ze{kbSYc1GN!nl>A?t30=zj!Y?_rNrIA~Gw=S7zU`zAAD#KCE=StR=w4cT08AgBQHRECn$10e3h1YbRlN zBeP>t+wc1+z)yT|zqo?>iiSpDAFP!b7hWarUMn5@`Qm4*WC{2YuX!${je<=uJ>!0K zfU;3l^v~m^3!W`#b`>4!dd6)KWy<++*G#6IdWN!Y3-*xh6dQi&@$PZwCvCoi*9?0q z-D!_nEWIjKPTAvGbCwV_2jV`(^Xd;YpBG@;56@FgIqZ-eDA&5SO}+iYEPZlr;f{*b z_uNScakXR=()F)+dtE91L@o)$=4Bn(LK+6M&}F;7x*nffxIaje)3{R>oJ_(LjRlgn z#vbrFI5CkB>8TlV$LR6VRw?^SvggS5*fO_z;zQry#cS@5mg5Op**TSVgYRzTx8#B( zosL?uY5x#_z4=kXU-Fzw0JsEcDynk9KE%OFS7h3>m2NB4FJr_K)*MvIbyFRhO2pD^ zY2?Y1H$%GIU&~y9Uv$l^uze}O5{+;_o_Il<#+ZaU?0^2U5p#uQVyS71_in>R12oTF zc>W4GblIrnl=O0P*E06Tpd>M3@Ye8-mQmi9F3aljm&E~tRd-YsD*Kkof)519m;XC? z-1RT-rD4@S_KGsjfp@ExJ+CN@3YdhI+dGaecSF~JgMpjHfZ27%H^8p9QFa{^-+m`s zdy@dCx?5`d`q4(qb8MqfoIOnZ!HKn_1Q_p-vK`r7<6^1h%b5_;=^@1^K+}&?lK8Iv zVtHL>^Gg9*aNc_64L%~M2k#tKdN{; zeAm9UFr0CHEqDbvq=}0P%BI$C7vdhX3j`Y?2j~*Cs`{@RjHk8Db|G92n?~JK~c1I=fHFMJt-c~Tj9ApGv7&Q}M{Q-N)!snoj~$+10Fu~T&OM7-g&F!r^j%a27w)%v6Wn1kN~AB3b0Rs}Rv6YILMfW5u~d*fGSn?qg=ZH}tdU&=)6_i1I872|D!F09 z7>2@F;NFsX8K+m?3{4IURv6Y_JNB7MyoaPkETF^ILNOtAGsPjmrnQks)eJmQ-Se5S zY5f;aI;bdayf{(~);Jq@1h%s&l4a$)Tmy`P_Ad~l6rgMS3XF7(^_38#)cFR7RNVX0 zpHPtYsJpZD%X9bFrPx8GUf2DSnLT+7UgHZJg<+kvrG3~3fmZ|USqn2jhmooB&+KNl zU^8cq4@*>WiBiB3o;0@UKP33TECU5f$xLhwI*g2S)kfXG4mt>6aEjlOTEL+H#80a5 zJmt|9b0{&T!FH^LIo@WT1{yNO06J_f5TKM}Tm9QxzM=dwnA3^~r5J^VRu9Z*2!&UE z>q)WAcCcl27TA#?Y${Pwnv^~2LGXyn&drPQj1Do(;X_2GfLCes#<)W%EB-eEl(OW1 z8#?c8814~FDI)ebORU%;gHS?JPdFtyKxwobdEZD}z9rRBBTu;jUk0w`pg<{^;mnnJ zMsJLf2a%%`4d_rxn10filHF`~F@ROjS_@2-(p0V+vCXlD)I{u}gw30yn|}RTrJMp) z2vN9v3|r$2rGVOg&cmp-L$DSRO3~gJl-Sld}*6k`OM zk&S9CtF}N{2r8EkxPoEB@`3Bh5lWG4ZN8DMapv;T!6zsMQQj)Ly2}(cv0budy)oD* zw2$Q1Z%yz?%zFo52v_bm0y1S$J4c05Ku}#^43u!@^3m}$7}$!oZK1+}_Bcua1~-IV z+V6=qyTTMgUw!JBHKW@~BWmxR;!IvcS}(phw2hfhYPlUJJ&aBE?jjW0fqR!`1>p zM8-^Qfw2%&WQrD4IYTKc;cpbe6df#qQV_$L*n?wM6{oC=xKYo9V!Scfk@5E6gx=o# zRd@)Qt7vnkvt{>NX@5kD1<3GSOTRWRU67f=igq`^04rz zFK6-^nurVw=+KcMNy7W{)KpX`WjTEfi7-X8H5k}RhIL2*AvA=_a~akYsMRKN7XC&Nk+G7xhREfky)h^wM1xY6-ER~w zpQ(rlW);Hn`ADi6%mh^kB}5XDVc8mIWQqxvphl_4y;V~|O=*AskX^H$0R^R@WI<^w zUrx%GlQPAyd^ss!PRf^)(q*eaDB;UV`EpXriaTFU%9oQ)BpXri<)nN$>63PWFstK0 zm0Qf0lbRZ*9OlbO`EpXGF9$2XLe&#Qg<4Dm6_$d5q5{H7z8jLFcMEzPXfJ{!d4bK4 zfMrpl$=b7`$ss7zqUBep1cayp!b(0XUruVNj}!7jEz6pm{YCFq;%SIbiix0d6&AFd z@5YytK6yJQ1Sn-Go`xzcXgQx1Mb@5K^ABid58cquzN}onoYd43&{D@5v+O5W3I?j& zVm5-x6`5k#8Z;P%B)6C^Cw;Q!e^%mYh{zOEpa4t3K!H+L^4*XWz0(PfsKbJ0%_P|j z2^iL*$=Wjmbm&OzCsR!J6PGAuIiD4Y%g2|K(rw`AhH4~m!+K*ffDSt`5abr~<)kz( zusbw{qEL(R#<)W%EABU6PD=M5X4x8NWQs2*r6p~qoio+Z`j0&f>E>Nr<`%PpDpzER zVQWyu7D;X~UrzdDZEyK%Mp%$Jk?rhzY= zzzjPvP~;Z#<)o&YhtR#u0YirNd*{nZpY*O2wAuo5G5B&)^P6k%<)o4H{eWn_0@k)3 zXcTnwE?9*Vs)!6LsKSbakQ8cJ41e?Gr0k7z_;OOn9i?D5&#@BzM&j~Wx)TClPWps* zIzfd}d^zbKZxjc;y_aQq4N-3n8qi@U247A}->}6>_?s^$HM{MdW%nC}%g2|KKB3FU k1WTaNRy*1dljWpq53QfyJz+O_uT{a!@QARZq4M|t51eFSYXATM diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/web/orange-square-element-only.png b/apps/playground/src/__tests__/ui/__image_snapshots__/web/orange-square-element-only.png deleted file mode 100644 index 64c61141ca118bff5c3297938204bb56da77ac8a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 323 zcmeAS@N?(olHy`uVBq!ia0vp^DImxdHmX`P}SB*2{BuV)GuyqB6C5vD+6xWV+=zVk0;>Szm) r1b;9BL*V~|r&{}fVZa6o17?O97g+CXgTe~DWM4fGrd}x diff --git a/apps/playground/src/__tests__/ui/__image_snapshots__/web/out-of-bounds.png b/apps/playground/src/__tests__/ui/__image_snapshots__/web/out-of-bounds.png deleted file mode 100644 index f2af63da6ec4d0106fb43101afdb26dfbb23a59f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 663 zcmeAS@N?(olHy`uVBq!ia0y~yV15B)r*JR<$yw38y$lRYKAtX)Ar*7pTyxBI4isQH zxHU!W;nVDQiW9{RIA=N-%*f}eF7XbPNL#Wm{_wT8JkiHDZCm%+?9CsGhqu1Jmz-wz zuYK { expect( config.serializer?.isThirdPartyModule?.({ - path: '/repo/apps/playground/src/__tests__/smoke.harness.ts', + path: '/repo/apps/playground/src/__tests__/normal/smoke.harness.ts', }), ).toBe(false); await expect( config.symbolicator?.customizeFrame?.({ - file: '/repo/apps/playground/src/__tests__/smoke.harness.ts', + file: '/repo/apps/playground/src/__tests__/normal/smoke.harness.ts', }), ).resolves.toEqual({ collapse: false, From 022ff3014a555bcfad24110648f9dbb265bf7484 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 16:08:51 +0200 Subject: [PATCH 09/17] chore: remove snapshots --- .../android/orange-square-element-only.png | Bin 1207 -> 0 bytes .../android/out-of-bounds.png | Bin 4096 -> 0 bytes .../chromium/orange-square-element-only.png | Bin 451 -> 0 bytes .../chromium/out-of-bounds.png | Bin 967 -> 0 bytes .../ios/orange-square-element-only.png | Bin 6006 -> 0 bytes .../__image_snapshots__/ios/out-of-bounds.png | Bin 51545 -> 0 bytes .../web/orange-square-element-only.png | Bin 451 -> 0 bytes .../__image_snapshots__/web/out-of-bounds.png | Bin 967 -> 0 bytes 8 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/out-of-bounds.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/orange-square-element-only.png delete mode 100644 apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/out-of-bounds.png diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png deleted file mode 100644 index 4902ce621133112ef192479572b4622e58ff5606..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1207 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Ca1v8whg%Nrw0P_nKPU02z$MLGDffR=lk!11og0XOh&>8OfU>(Ku(VF?Tho)hOD6~a3A~Fe4P}NmLR?EdZ0D}n1QJ%UG z?7)XqncA_&^`)QwBjKIRlJSSX3I6m}CG`PF^Tp+-(+5snfIYJUscQ>A11`-YC%RyJ zr5pAf=m0tW%+c%sv$6?)O#ARH09FHBx1V0#qp5hxehcia*Ju3-W+rF9-#)VG0jl96 z$~WEzn2Pm0SAZ>&lmaOQQVOILNGZS!*y|JQ4x?>2Hg}A)gP}X4rV}{x2S^D~ ArvLx| diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png deleted file mode 100644 index 861e1bd170d6523969492e21841b808f664ba5b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 451 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Rgs*VGbrB`F{UiAYx*MFg;xyLnRE@B-~!Vlqy0x6o{9JpnL5SA2>iU?DnF$7v-dTk%XMjdSd rk|0nQBP^gHCTIaEV1ToMf}0r{1*~_#0S=L2@O1Ta0WzHufCLu+a!bi> diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png deleted file mode 100644 index 50189eb87417a69d772362521144e33d587ce997..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 967 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Ca4A`~7=?yc7;5APHng12I^L$;Z>hF{A>*1(J@r z&Vd3f5N?VXgw6(1ia;!GzzHNB41h#FS9OVZs6-l&2}JP_3P|!qAKwJ$m_b+;Z~-7g z5{T`P1t9E12vxyj4P<~gc3@`$8Nxu!z#yOiq!<|#9Do!v1BfhO2l)ep`4|`&fs`x* z0~3(4W?*39C}&{c5CBP!Y8(w5U^*C(lmoPv6_$z>>kYxlk%0}A-IzhiR*w%zz>yR% RUov>Q`ndo_oDzTp7XX^RpFjWr diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png deleted file mode 100644 index 661b830ca0b093cb75c0349d85d757d34a692219..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6006 zcmeHL%}*0i5P#ccOKDlHXn+)mNFqU9upl)gfV5ZwrF^9jSO|qefLN-D388>N>dimF z#EYIy^uW!Sh6Lqeym(MAUOn?K_?w-_7D{g#y=*(*^LF0b**DX^JwC`!j1B7EfL92i z$A+Q_ArynqG45+6N0jl80;5N0QEqaKv&J!+UTgb3C$xkrAnwY6knkPgl5S? z3@dfEoKRZi7?%|wEz0DmIdJ-LY(%gn6(*$+8Jg?lD#d`N7Bm@PmV=0j$3wFlLR{-2 zR0d#ePhm)XIyycDjwM(h9^C?&C5yU2bKBV`LXZ@TMy9qE2!P1C8u8!%`W|BME=UEN zL4zzUG&RCRUQBM0zyM?b7hJ>%He*n^f*lUtdEk1{ zDBDl%5YSx|n5{=|!HYc3l>D5K87HxMXZz%xB01NC?C#mNSq?UHYkF>UnH0S zC$`hmSZ4*H&Cr3zwx^@*el7Cr)pOf&$Ce;W$Rowl&o>RWeRr>I%T`#>x0$;e%;Yje zIvN4wf1AQG=zmHL`#|psBSn94=zyaOpUIA%8F1H$7G5K-s~V&O`0e$H)&&6|W7GKv zun}ruK{GC_SmAF$pVz(4L{92binAI1ypDR6I2EdV>A=-i&dd`x1CYW%CDjW`v)Mog zo;j@*splM-Jw;%`5{pAB_3aA;WbR#$w_otU2fFpW)VXk*;XM&DUz^Y6$j|tJm zfB_HcJx(tAxupZr2(S&vkN~^EqGsqT0h-68#`pal^&`OT{*>ag?xs`FOM|uLP3d4Z z23Bg7S!#J(xl4uq1zn+Kg)V~tR&&g=)RWGw5xxxjnffW$E}*+Z7jd?kNm`K&lf^5A zCrS4XSY&3k&$9`KgHpdJLzP-DEhHdcd^MGoO#8HK=JF|CDYe?nk7qlGRC5gka5mvE z&^0yjN9--J(!$oDgKw#%J6prq8vkf(V490v3!uRV^kWjCx6HADu_)wzs&emdj`SAI diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png deleted file mode 100644 index 5bfee10ed6eaf23b35a5f5f80c99e790836067b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51545 zcmeHQ>u(g-72ol~g3X#32aIiKSR7~)$Aoq4V8;otah+hGahwFqtH7EVL#Q#P!F9kU zI7(Zot<-*LKUHm|d~8+a^&yqgC{iA6)h12Ts){O7(hrsTt^F7J``vTr&dlz5U$8Ti zYV32LJC8HxKF+!4o_p_q-+6mc8*29gA6AD-+0j8VSHD zM6KtZ+A|oF^hR|J6Q>XFoqYW9SSK!68Cz~O$Kt#c|1qmQwp_(yv3=GC6MxFOTVGQb z;{%?JEz?&WMaVySHlAQ&ab#J}laZ>k?;^H(2;fIS>qCvm?p_9lslSrmV({hFJ+nA9tNnYoXF)2zk6)o-3Zn`tsb{&!!VID@Z2dAZJJt@Mm;%8N_XB)d}cwg^l1Gt zM0FF6Id7HwrqjBJhb5i(x`Ybbty4&htl1IQA0>F2!j&uG4}63&U3>TsKY8pjQV(7p zF~3NJb@LSIe;=GvrL0e0J&O!0(U}-}@8xk-MN}bNxUi0ou;PvJx;aGVH0{nF?nSCF z?N0E*Sw0V7@iVW*`QbBgs%`N78|vpbA(()(;P#z5c9EZUEw0H%T-8QJgKg6;3LKO>;Crbc*~EZVhxh`B zGT?<}62c$YDNz^we*Z0GP1Ak$Dtr{iwZAx(2|3AhQ+KMprBTro>5Nm#`aQ_(A|dtX zT;!*&J_HZI#v+hxPu$N+cITj^Tl+6aM7QG0sCy~on7WU+>s>36WRVFxBEfkCr{Q{< zRHcyQT-y)F7oI*9f>f-sxsSU&C zH&z&FWw|%nZ9dhy>ki!w;M%=ub1Nde2Ec|{hmfTRcveUwiKh_giXj<^Aqm%zAT~~} z0+nAC=W<^l+1KA{V~^q~6a3QqJ!1%`djb>T%E1y;&r?GU@?npo=Z6RaW zWZts&jwkY255t5^*J2|eoJC>rkKIPR$&EXFs}XzDHm(&WMNb00D$q_E-~@9 zv2!pDV>{bQb2{z_8QW%b?KL;CUI;wcjSxc|3IwcXF*snxDqVMJ&8~QI!T-(y?*^>d zJU_IT4b=Kgq*=kn+MbgZRyT;W~aGdA~N13HZ`xYjr;r0lLWm~EC6QH*?B?ryBsbGD;{p0VGqA+HKiY$ zsoGCCggj_6;MZ~}P;97$R|Afcpq+3KNQK6ldP)+rvAqu$XlzqtRXI>dXTZ9uL>kR( zR=oEza%P*86D*f=EY37s22LP5Mz(c-N>ZBB6&cWuQxBBs@7m~82OC{~i0hFHi;D&L zri6S~1If#3@z*!tO=mvy(30|-T*n{bzB%6q`5qCEUZH?mL+(2J0>WkNm)Ks99@s|B zT1xjjx|G{XR$JV92b=(Vq~s;`djPio+71p_{2m1W(&yj+&yL)mV z3HSo={9J`azGg)}1-?gQHMtTw*^4mqJ5>u1yVt1)shK%;Q$xBrsvZOVPC-2~D;3}Z z+~8DCKl`|aPhOKu_F0}`ypaO&Ly0Ooy`6oNJ*q^smTCb!eg&KX3SSGqC1ngOt#aWf z3$XP9q%oyT_CCav45&G${BYBn0z8KL1O%(#)Tk3-QSDVA0>KBQ<5f5Sdo712hEy03 zRO{wM!Y*?hOD4gBcKVN5KNJPz90qpYOU|@G(xr%ttFENw()9>98PM%Vot0PS2#2MJ zEH@cH5G@zpt~lrr`Dgfq1NK>f9bNFG2STJoCo8x3FBfF+Vz(ZnI5h=Jdv=g)p{7Gt zCA947nE3d6^5U`;+U$yLz2BMHf3VxDt$0OB6q(P;(Z2?z0`LfdK)FPn^p){bO9%Lx z1RQvZ0$$Sa!^ifO@{2k`fL4)|9IN6|r5ET}P&`e`b+weJ3Qr7|E2*+Cqo}|saRh+c zPXqQ-(ZjLmN&QMS!C#WJ`r-i806^8ABd5L{NkAyMe&uso@-fB_gx^!Hx5pn#D^?AT zS2s5IBcUf@Yp1ZDkT>p;08_(=ke{BDi~>rPE%-&v8FKvL*i{{u8&s!<=q=Q$%7eux zdX-IJ`KJ0OCC4~sYl5@F%jQ|SfP-~`GONNX3QYDE zAAJU-=%-?*bI8`ruzw8^gST}Jhk%yo8p5XB2V#Kba59U8NU3q>%;&%Q^N-~|V^u53 zha`Y+&-x-dY%(b*-V)PE^F*U3FO#h1#;nA)w^K*uu+(tQO8Gj{uiEm@f3{zTvO9km zp2sjLe0*Oi_Pv@2sebU65~Vl<5l}PPCbbW-n$3}19s2#x)M|^yQYI)AM(6Imv=sqG z1Axsn??XJ478AfL5IueYk*{bK1)hGec+BeJP1NJqeRcTQyIrS%BsClD2#Xp_8#4)+ ziN)n@wUUtXvXnXaWFxidIr(s6ORF`2AaEY(pNT zYOh>YW}0{tO^g2dn->u)r5g#>Xg6Q&5a!{$%Lq1<48D2`2vijRnR}HpOyB4OwrWkG zQc=`)>N9VWHq55Gpi91a5i&zIss~11tphULu=HFgt{R7Pt$vRhdxaKztjU1zT0#hZ#`o1`H(v-W zMPNS$_&Kw-VdndsEWG}z6rP!8M>0&Bd|G`Al(PV;Y4s{xtQT7QwdR3Bi2-fn%4w}K zR+k23m20rA-1@=X8jvdAD*+alPz|m@2n1>l_c^mojKKww5DEjFBT~jhMF*&Q>8^Z> zYGpks^I=r})qE)_-ci=_C&9`$n_RrkYw22)jJP@|MScOTc)1dkkkt78v3e!?RW{!Q zEdAj1LX71IWEQtf-RZlF*MEt7E^>+%Eymu%qxNG`ysvq+hnVcwqtiB0(d>V}C)=QC1Y6F_o)*WBn9C%iTdD5UyX18N}D@YJ!ium>AHD z2*NlI<-3h;&Te919^dEN6oru0Qwag6W{DGS^PSOiykl%#5ohyXT3>=60axJ$$!{r> z8rTpz8;RgdazT`Qc@LT8P#2d4F}`H>ohG5PBDjR4sok}|ciWwn~XRV-~D z*VcA%16}|y#;%BeA#8N>_2Qu);tQo62~X0cL{i^*f5kf+1-T)Y(9pI)Dxr|Z#Y1qN znf375#sFtRyyVG$J6)kkh5JJE$z4H=^YLb{u3S(t{?gVG!HEHtPe_^12|@(Oo!>3D zo23ed;-CS4cIV8qNh)cT;-g_NXkm{zUHe>yYNYQmlLfvZ5(Y-4D(#k&L6Eo|Zr`0a z5M+=Cg2=DNkB$SuqRQwv5RXBeGl}6D;YP=S*xM1J^|*FOcD77Z^Sse#m9 z$1GfQ=%>cf9REeffkelFI4Aj_LU08SYd9-n%45|3OPOkc($x`^$7B7 z?N#KtLT4$PH?CZyf>NFJGbdO^$ALu0fvC!S)0=!&zIBow5=%u4@y1D7qz0Y?aM5uf zAVPE;h~_^2SKIR0lr$_>S?b7a=4nO8fg}xOmOLBGTw!$nWm|N4PED-csu*BcKusce zmsFR_WfYwN(QzPZ+A#kBn{tgggp9_Ijssz>@}3M>*(OVY?EY=aFGsU4`9%h_(QzQ| z6Jmv2(QzQIEy_b>53emxqT@i!5v{5N4MueClXYw~G;G&)Z93viyO20L6kXshKukSA#5n;C z309n2aVjVt6CDSl8e47nJ)3u#qUGaE^`xA<29^q~6yJ#PgT#OBI11Hny)!lMVwh&%T`D~h2d3l0#U#o32@kGbR39?#zGMIHiLAnRG=35R+~YX z_p!79zMk-9%XgK`CHczDg(mhCt)xnsmdRD(N5_G@^L8<#-F&q}n1}POonu0|O=$HN z5U8#GGxsWIn4;rAmQskI6sy>b$@ft`(DZ5@q^~so(i>dhl5x1`IFQ8s1$6h(aUfn( zj7)D=J9ozy(6R;+*~7i?N6atD1KSX95zS$`Z>|P@X2eE*@i$Lke)9DmLjkp1ALB9$2P;`B#xB` z^LC*e7e>c{fCvWc!80Rbn|P&x=s1wGt*rS5tVD;) zJi}xoQZ=#5J%--AERemz@b(MQaUiNDVD6mq_pB61Ted0*OO2xOgy=XB3JFgOEtMjb zv{hppJ$td@y_bimxGb*^dL3A95G%)e0o6)*b&%SdWJI$i3o=ONnbR0+r#rW%Q2|3{V zZzB=hKH&J=e0dL<9I|jPLqKcRY)-v`tl)ft>`Wkt@x5zO7f3|Mfs8m?d5T5PXm_f0 z*BwqF6`Rj5Z!J@F9Ehp$#fX`&7Z3dqU%tzc@FZPIB#n*(Q61nC%N&G;whhu*B#WF+ zjbDf0qT@h71a?{Zdzl~;kYfT{f~q0de8bU|%Lf>LX={n##DK~tq|E08Ap%sp(GgWJ zRKFOAjssajHBo%&lhDt%k$$BcMfkX2XH-bBn`?*`ShcoG295`b?R|8FUHc%_b`Adn D%|b9I diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/orange-square-element-only.png deleted file mode 100644 index 861e1bd170d6523969492e21841b808f664ba5b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 451 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Rgs*VGbrB`F{UiAYx*MFg;xyLnRE@B-~!Vlqy0x6o{9JpnL5SA2>iU?DnF$7v-dTk%XMjdSd rk|0nQBP^gHCTIaEV1ToMf}0r{1*~_#0S=L2@O1Ta0WzHufCLu+a!bi> diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/web/out-of-bounds.png deleted file mode 100644 index 50189eb87417a69d772362521144e33d587ce997..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 967 zcmaFAe{X=FJ1>_M7Xt$WucwDg5Ca4A`~7=?yc7;5APHng12I^L$;Z>hF{A>*1(J@r z&Vd3f5N?VXgw6(1ia;!GzzHNB41h#FS9OVZs6-l&2}JP_3P|!qAKwJ$m_b+;Z~-7g z5{T`P1t9E12vxyj4P<~gc3@`$8Nxu!z#yOiq!<|#9Do!v1BfhO2l)ep`4|`&fs`x* z0~3(4W?*39C}&{c5CBP!Y8(w5U^*C(lmoPv6_$z>>kYxlk%0}A-IzhiR*w%zz>yR% RUov>Q`ndo_oDzTp7XX^RpFjWr From 17c28a347ea750e5327317f28b8529d6fc2cef6e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 08:08:32 +0200 Subject: [PATCH 10/17] chore: update iOS version --- apps/playground/rn-harness.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 0b278b46..559f19f8 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -79,7 +79,7 @@ export default { }), applePlatform({ name: 'ios', - device: appleSimulator('iPhone 17 Pro', '26.4'), + device: appleSimulator('iPhone 17 Pro', '26.2'), bundleId: 'com.harnessplayground', }), applePlatform({ From 162df1fc357a7fcc159563414325a99d76f5a0ed Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 08:11:34 +0200 Subject: [PATCH 11/17] ci: temporarily disable crash validation jobs --- .github/workflows/e2e-tests.yml | 477 ++++++++++++++++---------------- 1 file changed, 239 insertions(+), 238 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 676b4fde..9c1ffa4a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -267,241 +267,242 @@ jobs: path: apps/playground/.harness/logs if-no-files-found: ignore - crash-validate-android: - name: Crash Validation Android - runs-on: ubuntu-22.04 - timeout-minutes: 30 - if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} - env: - HARNESS_DEBUG: true - DEBUG: 'Metro:*' - - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.ref }} - fetch-depth: 0 - - - name: Reclaim disk space - uses: AdityaGarg8/remove-unwanted-software@90e01b21170618765a73370fcc3abbd1684a7793 # v5 - with: - remove-dotnet: true - remove-haskell: true - remove-codeql: true - remove-docker-images: true - - - name: Install pnpm - uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 - with: - version: latest - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '24.10.0' - cache: 'pnpm' - - - name: Install dependencies - run: | - pnpm install - - - name: Build packages - run: | - pnpm nx run-many -t build --projects="packages/*" - - - name: Set up JDK 17 - uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3.14.1 - with: - java-version: '17' - distribution: 'temurin' - - - name: Restore APK from cache - id: cache-apk-restore - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - key: apk-playground - - - name: Build Android app - if: steps.cache-apk-restore.outputs.cache-hit != 'true' - working-directory: apps/playground - run: | - pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug - - - name: Save APK to cache - if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - key: apk-playground - - - name: Run React Native Harness (expect crash) - id: crash-test - continue-on-error: true - uses: ./ - with: - app: android/app/build/outputs/apk/debug/app-debug.apk - runner: android-crash-pre-rn - projectRoot: apps/playground - harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/android' - preRunHook: | - echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" - echo "HARNESS_RUNNER=$HARNESS_RUNNER" - afterRunHook: | - echo "HARNESS_RUNNER=$HARNESS_RUNNER" - echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" - - - name: Verify crash was detected - shell: bash - run: | - if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then - echo "ERROR: Expected harness to fail (crash not detected)" - exit 1 - fi - echo "Crash was correctly detected by the harness" - - - name: Verify crash artifacts exist - shell: bash - run: | - CRASH_DIR="apps/playground/.harness/crash-reports" - if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then - echo "Crash report artifacts found:" - ls -la "$CRASH_DIR" - else - echo "ERROR: No crash report artifacts found in $CRASH_DIR" - exit 1 - fi - - - name: Upload Harness logs - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: harness-logs-crash-validate-android - path: apps/playground/.harness/logs - if-no-files-found: ignore - - crash-validate-ios: - name: Crash Validation iOS - runs-on: macos-latest - timeout-minutes: 30 - if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} - env: - HARNESS_DEBUG: true - DEBUG: 'Metro:*' - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.ref }} - fetch-depth: 0 - - - name: Install pnpm - uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 - with: - version: latest - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '24.10.0' - cache: 'pnpm' - - - name: Setup Xcode 26 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: '26.0' - - - name: Install Watchman - run: brew install watchman - - - name: Install dependencies - run: | - pnpm install - - - name: Build packages - run: | - pnpm nx run-many -t build --projects="packages/*" - - - name: Restore app from cache - id: cache-app-restore - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - key: ios-app-playground - - - name: CocoaPods cache - if: steps.cache-app-restore.outputs.cache-hit != 'true' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ./apps/playground/ios/Pods - ~/Library/Caches/CocoaPods - ~/.cocoapods - key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} - restore-keys: | - playground-${{ runner.os }}-pods- - - - name: Install CocoaPods - if: steps.cache-app-restore.outputs.cache-hit != 'true' - working-directory: apps/playground/ios - run: | - pod install - - - name: Build iOS app - if: steps.cache-app-restore.outputs.cache-hit != 'true' - working-directory: apps/playground - run: | - pnpm react-native build-ios --buildFolder ./build --verbose - - - name: Save app to cache - if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - key: ios-app-playground - - - name: Run React Native Harness (expect crash) - id: crash-test - continue-on-error: true - uses: ./ - with: - app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - runner: ios-crash-pre-rn - projectRoot: apps/playground - harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/ios' - preRunHook: | - echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" - echo "HARNESS_RUNNER=$HARNESS_RUNNER" - afterRunHook: | - echo "HARNESS_RUNNER=$HARNESS_RUNNER" - echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" - - - name: Verify crash was detected - shell: bash - run: | - if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then - echo "ERROR: Expected harness to fail (crash not detected)" - exit 1 - fi - echo "Crash was correctly detected by the harness" - - - name: Verify crash artifacts exist - shell: bash - run: | - CRASH_DIR="apps/playground/.harness/crash-reports" - if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then - echo "Crash report artifacts found:" - ls -la "$CRASH_DIR" - else - echo "ERROR: No crash report artifacts found in $CRASH_DIR" - exit 1 - fi - - - name: Upload Harness logs - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: harness-logs-crash-validate-ios - path: apps/playground/.harness/logs - if-no-files-found: ignore + # Temporary disable: crash detector validation jobs. + # crash-validate-android: + # name: Crash Validation Android + # runs-on: ubuntu-22.04 + # timeout-minutes: 30 + # if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} + # env: + # HARNESS_DEBUG: true + # DEBUG: 'Metro:*' + + # steps: + # - name: Checkout code + # uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + # with: + # ref: ${{ github.ref }} + # fetch-depth: 0 + + # - name: Reclaim disk space + # uses: AdityaGarg8/remove-unwanted-software@90e01b21170618765a73370fcc3abbd1684a7793 # v5 + # with: + # remove-dotnet: true + # remove-haskell: true + # remove-codeql: true + # remove-docker-images: true + + # - name: Install pnpm + # uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 + # with: + # version: latest + + # - name: Setup Node.js + # uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + # with: + # node-version: '24.10.0' + # cache: 'pnpm' + + # - name: Install dependencies + # run: | + # pnpm install + + # - name: Build packages + # run: | + # pnpm nx run-many -t build --projects="packages/*" + + # - name: Set up JDK 17 + # uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3.14.1 + # with: + # java-version: '17' + # distribution: 'temurin' + + # - name: Restore APK from cache + # id: cache-apk-restore + # uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + # key: apk-playground + + # - name: Build Android app + # if: steps.cache-apk-restore.outputs.cache-hit != 'true' + # working-directory: apps/playground + # run: | + # pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug + + # - name: Save APK to cache + # if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() + # uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + # key: apk-playground + + # - name: Run React Native Harness (expect crash) + # id: crash-test + # continue-on-error: true + # uses: ./ + # with: + # app: android/app/build/outputs/apk/debug/app-debug.apk + # runner: android-crash-pre-rn + # projectRoot: apps/playground + # harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/android' + # preRunHook: | + # echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" + # echo "HARNESS_RUNNER=$HARNESS_RUNNER" + # afterRunHook: | + # echo "HARNESS_RUNNER=$HARNESS_RUNNER" + # echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + + # - name: Verify crash was detected + # shell: bash + # run: | + # if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then + # echo "ERROR: Expected harness to fail (crash not detected)" + # exit 1 + # fi + # echo "Crash was correctly detected by the harness" + + # - name: Verify crash artifacts exist + # shell: bash + # run: | + # CRASH_DIR="apps/playground/.harness/crash-reports" + # if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then + # echo "Crash report artifacts found:" + # ls -la "$CRASH_DIR" + # else + # echo "ERROR: No crash report artifacts found in $CRASH_DIR" + # exit 1 + # fi + + # - name: Upload Harness logs + # if: always() + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # with: + # name: harness-logs-crash-validate-android + # path: apps/playground/.harness/logs + # if-no-files-found: ignore + + # crash-validate-ios: + # name: Crash Validation iOS + # runs-on: macos-latest + # timeout-minutes: 30 + # if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} + # env: + # HARNESS_DEBUG: true + # DEBUG: 'Metro:*' + # steps: + # - name: Checkout code + # uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + # with: + # ref: ${{ github.ref }} + # fetch-depth: 0 + + # - name: Install pnpm + # uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 + # with: + # version: latest + + # - name: Setup Node.js + # uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + # with: + # node-version: '24.10.0' + # cache: 'pnpm' + + # - name: Setup Xcode 26 + # uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + # with: + # xcode-version: '26.0' + + # - name: Install Watchman + # run: brew install watchman + + # - name: Install dependencies + # run: | + # pnpm install + + # - name: Build packages + # run: | + # pnpm nx run-many -t build --projects="packages/*" + + # - name: Restore app from cache + # id: cache-app-restore + # uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + # key: ios-app-playground + + # - name: CocoaPods cache + # if: steps.cache-app-restore.outputs.cache-hit != 'true' + # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: | + # ./apps/playground/ios/Pods + # ~/Library/Caches/CocoaPods + # ~/.cocoapods + # key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} + # restore-keys: | + # playground-${{ runner.os }}-pods- + + # - name: Install CocoaPods + # if: steps.cache-app-restore.outputs.cache-hit != 'true' + # working-directory: apps/playground/ios + # run: | + # pod install + + # - name: Build iOS app + # if: steps.cache-app-restore.outputs.cache-hit != 'true' + # working-directory: apps/playground + # run: | + # pnpm react-native build-ios --buildFolder ./build --verbose + + # - name: Save app to cache + # if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() + # uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # with: + # path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + # key: ios-app-playground + + # - name: Run React Native Harness (expect crash) + # id: crash-test + # continue-on-error: true + # uses: ./ + # with: + # app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + # runner: ios-crash-pre-rn + # projectRoot: apps/playground + # harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/ios' + # preRunHook: | + # echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" + # echo "HARNESS_RUNNER=$HARNESS_RUNNER" + # afterRunHook: | + # echo "HARNESS_RUNNER=$HARNESS_RUNNER" + # echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + + # - name: Verify crash was detected + # shell: bash + # run: | + # if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then + # echo "ERROR: Expected harness to fail (crash not detected)" + # exit 1 + # fi + # echo "Crash was correctly detected by the harness" + + # - name: Verify crash artifacts exist + # shell: bash + # run: | + # CRASH_DIR="apps/playground/.harness/crash-reports" + # if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then + # echo "Crash report artifacts found:" + # ls -la "$CRASH_DIR" + # else + # echo "ERROR: No crash report artifacts found in $CRASH_DIR" + # exit 1 + # fi + + # - name: Upload Harness logs + # if: always() + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # with: + # name: harness-logs-crash-validate-ios + # path: apps/playground/.harness/logs + # if-no-files-found: ignore From da4377460fdcc37900aa19ef8f1b6e2657fdae80 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 10:10:59 +0200 Subject: [PATCH 12/17] Add session-scoped crash artifact collection across platforms. Wire AppSession crash reporters on iOS and Android, persist extracted artifacts through the harness writer, and enrich Android crashes with DropBox and exit-info dumps when logcat evidence is missing or incomplete. --- .../jest/src/__tests__/crash-monitor.test.ts | 84 +++- packages/jest/src/__tests__/errors.test.ts | 37 ++ packages/jest/src/crash-monitor.ts | 87 +++- packages/jest/src/errors.ts | 16 + packages/jest/src/harness-session.ts | 8 + .../src/__tests__/crash-diagnostics.test.ts | 100 ++++ .../src/__tests__/crash-reporter.test.ts | 167 ++++++ packages/platform-android/src/adb.ts | 39 ++ packages/platform-android/src/app-session.ts | 37 +- .../platform-android/src/crash-diagnostics.ts | 475 ++++++++++++++++++ .../platform-android/src/crash-reporter.ts | 263 ++++++++++ packages/platform-android/src/instance.ts | 31 +- packages/platform-android/src/runner.ts | 10 +- .../src/__tests__/crash-diagnostics.test.ts | 39 +- packages/platform-ios/src/app-session.ts | 9 +- .../platform-ios/src/crash-diagnostics.ts | 89 ++-- packages/platform-ios/src/crash-reporter.ts | 60 +++ packages/platform-ios/src/instance.ts | 80 ++- packages/platform-ios/src/runner.ts | 6 +- packages/platforms/src/index.ts | 2 + packages/platforms/src/types.ts | 29 +- .../src/__tests__/crash-artifacts.test.ts | 69 ++- packages/tools/src/crash-artifacts.ts | 16 +- 23 files changed, 1633 insertions(+), 120 deletions(-) create mode 100644 packages/platform-android/src/__tests__/crash-diagnostics.test.ts create mode 100644 packages/platform-android/src/__tests__/crash-reporter.test.ts create mode 100644 packages/platform-android/src/crash-diagnostics.ts create mode 100644 packages/platform-android/src/crash-reporter.ts create mode 100644 packages/platform-ios/src/crash-reporter.ts diff --git a/packages/jest/src/__tests__/crash-monitor.test.ts b/packages/jest/src/__tests__/crash-monitor.test.ts index 73e56963..36c3a350 100644 --- a/packages/jest/src/__tests__/crash-monitor.test.ts +++ b/packages/jest/src/__tests__/crash-monitor.test.ts @@ -19,6 +19,7 @@ const waitForClassification = () => const createAppSessionMock = ( initialState: AppSessionState = { status: 'running' }, logs: AppSessionLog[] = [], + getCrashDetails?: AppSession['getCrashDetails'] ) => { let state = initialState; let listener: AppSessionListener | null = null; @@ -35,6 +36,10 @@ const createAppSessionMock = ( }), }; + if (getCrashDetails) { + session.getCrashDetails = getCrashDetails; + } + return { session, setState: (nextState: AppSessionState) => { @@ -82,7 +87,11 @@ describe('createCrashMonitor', () => { watch.promise.catch(noop); cm.handleBridgeDisconnect(); - setState({ status: 'exited', occurredAt: Date.now(), reason: 'process-gone' }); + setState({ + status: 'exited', + occurredAt: Date.now(), + reason: 'process-gone', + }); await expect(watch.promise).rejects.toBeInstanceOf(NativeCrashError); }); @@ -104,7 +113,10 @@ describe('createCrashMonitor', () => { { line: 'ordinary app log', occurredAt }, { line: 'MyApp[123] fatal error: boom', occurredAt }, ]; - const { session, setState } = createAppSessionMock({ status: 'running' }, logs); + const { session, setState } = createAppSessionMock( + { status: 'running' }, + logs + ); const cm = createCrashMonitor({ appSession: session }); const watch = cm.watch('test.ts', 'execution'); watch.promise.catch(noop); @@ -117,13 +129,79 @@ describe('createCrashMonitor', () => { expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']); }); + it('asks the app session to extract native crash details for the current test', async () => { + const occurredAt = Date.now(); + const getCrashDetails = vi.fn(async () => ({ + artifactType: 'logcat' as const, + artifactPath: '/tmp/.harness/crash-reports/crash-logcat.txt', + processName: 'com.harnessplayground', + pid: 7777, + })); + const { session, setState } = createAppSessionMock( + { status: 'running' }, + [], + getCrashDetails + ); + const cm = createCrashMonitor({ appSession: session }); + const watch = cm.watch('/test/example.ts', 'execution'); + watch.promise.catch(noop); + + cm.handleBridgeDisconnect(); + setState({ + status: 'exited', + occurredAt, + pid: 7777, + reason: 'process-gone', + }); + + const error = await watch.promise.catch((err: NativeCrashError) => err); + + expect(getCrashDetails).toHaveBeenCalledWith({ + occurredAt: expect.any(Number), + pid: 7777, + processName: undefined, + testFilePath: '/test/example.ts', + }); + expect(error.details.artifactPath).toBe( + '/tmp/.harness/crash-reports/crash-logcat.txt' + ); + expect(error.message).toContain( + 'Harness extracted the crash log: /tmp/.harness/crash-reports/crash-logcat.txt' + ); + }); + + it('falls back to log details when native crash extraction fails', async () => { + const occurredAt = Date.now(); + const getCrashDetails = vi.fn(async () => { + throw new Error('copy failed'); + }); + const { session, setState } = createAppSessionMock( + { status: 'running' }, + [{ line: 'MyApp[123] fatal error: boom', occurredAt }], + getCrashDetails + ); + const cm = createCrashMonitor({ appSession: session }); + const watch = cm.watch('/test/example.ts', 'execution'); + watch.promise.catch(noop); + + cm.handleBridgeDisconnect(); + setState({ status: 'exited', occurredAt, reason: 'process-gone' }); + + const error = await watch.promise.catch((err: NativeCrashError) => err); + + expect(error.details.rawLines).toEqual(['MyApp[123] fatal error: boom']); + expect(error.message).toContain("Harness couldn't extract the crash log."); + }); + it('settles the promise with CrashWatchCancelledError on cancel()', async () => { const cm = createCrashMonitor(); const watch = cm.watch('test.ts', 'execution'); watch.cancel(); - await expect(watch.promise).rejects.toBeInstanceOf(CrashWatchCancelledError); + await expect(watch.promise).rejects.toBeInstanceOf( + CrashWatchCancelledError + ); }); it('ignores session events while stopped', async () => { diff --git a/packages/jest/src/__tests__/errors.test.ts b/packages/jest/src/__tests__/errors.test.ts index 34e4ff4f..cdb427e1 100644 --- a/packages/jest/src/__tests__/errors.test.ts +++ b/packages/jest/src/__tests__/errors.test.ts @@ -10,6 +10,43 @@ describe('PlatformReadyTimeoutError', () => { }); describe('NativeCrashError', () => { + it('reports the extracted crash log path when available', () => { + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'execution', + artifactPath: '/tmp/.harness/crash-reports/crash.ips', + }); + + expect(error.message).toContain( + 'Harness extracted the crash log: /tmp/.harness/crash-reports/crash.ips' + ); + }); + + it('lists enrichment artifact paths when available', () => { + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'execution', + artifactPath: '/tmp/.harness/crash-reports/logcat.txt', + enrichmentArtifacts: [ + { + artifactType: 'dropbox-native-crash', + artifactPath: '/tmp/.harness/crash-reports/dropbox-native-crash.txt', + }, + ], + }); + + expect(error.message).toContain('Additional crash artifacts:'); + expect(error.message).toContain( + '/tmp/.harness/crash-reports/dropbox-native-crash.txt' + ); + }); + + it('reports crash log extraction failure when no artifact was pulled', () => { + const error = new NativeCrashError('/tmp/crash.harness.ts', { + phase: 'execution', + }); + + expect(error.message).toContain("Harness couldn't extract the crash log."); + }); + it('formats the extracted stack trace in the error message', () => { const error = new NativeCrashError('/tmp/crash.harness.ts', { phase: 'execution', diff --git a/packages/jest/src/crash-monitor.ts b/packages/jest/src/crash-monitor.ts index 781144b9..e43ac790 100644 --- a/packages/jest/src/crash-monitor.ts +++ b/packages/jest/src/crash-monitor.ts @@ -1,8 +1,10 @@ import { + type AppCrashDetails, type AppSession, type AppSessionEvent, type AppSessionListener, type AppSessionLog, + type AppSessionState, } from '@react-native-harness/platforms'; import { NativeCrashError, @@ -51,27 +53,28 @@ type PendingCrash = { occurredAt: number; }; -const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const isCrashIndicator = (line: string) => /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason|crash|abort/i.test( - line, + line ) || /\bSIG[A-Z]{2,}\b/.test(line); const getMatchingCrashLines = ( logs: AppSessionLog[], - occurredAt: number, + occurredAt: number ): string[] => logs - .filter((log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS) + .filter( + (log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS + ) .map((log) => log.line) .filter(isCrashIndicator); const buildNativeCrashDetails = ( phase: NativeCrashPhase, rawLines: string[], - summary: string, + summary: string ): NativeCrashDetails => ({ phase, source: rawLines.length > 0 ? 'logs' : 'bridge', @@ -79,9 +82,35 @@ const buildNativeCrashDetails = ( rawLines: rawLines.length > 0 ? rawLines : undefined, }); +const getStatePid = (state: AppSessionState | undefined) => { + if (state && 'pid' in state) { + return state.pid; + } + + return undefined; +}; + +const mergeCrashDetails = ( + fallback: NativeCrashDetails, + extracted: AppCrashDetails | null +): NativeCrashDetails => { + if (!extracted) { + return fallback; + } + + return { + ...fallback, + ...extracted, + phase: fallback.phase, + source: extracted.source ?? fallback.source, + summary: extracted.summary ?? fallback.summary, + rawLines: extracted.rawLines ?? fallback.rawLines, + }; +}; + const buildRuntimeDisconnectDetails = ( phase: NativeCrashPhase, - rawLines: string[], + rawLines: string[] ): RuntimeDisconnectDetails => ({ phase, source: 'bridge', @@ -119,7 +148,7 @@ export const createCrashMonitor = ({ const classify = async ( pending: PendingCrash, - trigger: 'bridge-disconnect' | 'app-exit', + trigger: 'bridge-disconnect' | 'app-exit' ) => { if (disposed || !monitoring || isResolvingCrash) { return; @@ -134,12 +163,14 @@ export const createCrashMonitor = ({ const rawLines = getMatchingCrashLines(logs, pending.occurredAt); if (state?.status === 'running' && trigger === 'bridge-disconnect') { - crashLogger.debug('runtime bridge disconnected without confirmed app death'); + crashLogger.debug( + 'runtime bridge disconnected without confirmed app death' + ); notifyFailure( new RuntimeDisconnectError( pending.testFilePath, - buildRuntimeDisconnectDetails(pending.phase, rawLines), - ), + buildRuntimeDisconnectDetails(pending.phase, rawLines) + ) ); return; } @@ -157,9 +188,30 @@ export const createCrashMonitor = ({ const details = buildNativeCrashDetails( pending.phase, rawLines, - fallbackSummary, + fallbackSummary + ); + const extractedDetails = session?.getCrashDetails + ? await session + .getCrashDetails({ + occurredAt: pending.occurredAt, + pid: getStatePid(state), + processName: details.processName, + testFilePath: pending.testFilePath, + }) + .catch((error) => { + crashLogger.warn( + 'failed to extract native crash details: %s', + error + ); + return null; + }) + : null; + notifyFailure( + new NativeCrashError( + pending.testFilePath, + mergeCrashDetails(details, extractedDetails) + ) ); - notifyFailure(new NativeCrashError(pending.testFilePath, details)); } finally { isResolvingCrash = false; pendingTimer = null; @@ -178,9 +230,12 @@ export const createCrashMonitor = ({ occurredAt: Date.now(), }; - pendingTimer = setTimeout(() => { - void classify(pending, trigger); - }, trigger === 'bridge-disconnect' ? CRASH_CLASSIFICATION_SETTLE_MS : 0); + pendingTimer = setTimeout( + () => { + void classify(pending, trigger); + }, + trigger === 'bridge-disconnect' ? CRASH_CLASSIFICATION_SETTLE_MS : 0 + ); }; const appSessionListener: AppSessionListener = (event: AppSessionEvent) => { diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index 26c7d290..1caf8ec1 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -68,12 +68,28 @@ const buildNativeCrashMessage = ({ pid, stackTrace, artifactType, + artifactPath, + enrichmentArtifacts, }: NativeCrashDetails) => { const lines = [ phase === 'startup' ? 'The native app crashed while preparing to run this test file.' : 'The native app crashed during test execution.', ]; + + lines.push( + artifactPath + ? `Harness extracted the crash log: ${artifactPath}` + : "Harness couldn't extract the crash log." + ); + + if (enrichmentArtifacts && enrichmentArtifacts.length > 0) { + lines.push('Additional crash artifacts:'); + for (const artifact of enrichmentArtifacts) { + lines.push(` - ${artifact.artifactPath}`); + } + } + const hasCrashBlock = summary?.includes('\n') ?? false; const shouldRenderSummary = Boolean(summary) && diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index d8faaf72..2642ee0d 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -34,6 +34,7 @@ import { type HarnessRunSummary, } from '@react-native-harness/plugins'; import { + createCrashArtifactWriter, logger, getTimeoutSignal, raceAbortSignals, @@ -45,6 +46,7 @@ import { } from '@react-native-harness/config'; import type { Config as JestConfig } from 'jest-runner'; import { preRunMessage } from 'jest-util'; +import path from 'node:path'; import { PlatformReadyTimeoutError } from './errors.js'; import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js'; import { createCrashMonitor, type CrashMonitor } from './crash-monitor.js'; @@ -453,6 +455,11 @@ export const createHarnessSession = async ( const clientLogCollector = createClientLogCollector(); const context: HarnessContext = { platform }; + const crashArtifactWriter = createCrashArtifactWriter({ + runnerName: platform.name, + platformId: platform.platformId, + rootDir: path.join(projectRoot, '.harness', 'crash-reports'), + }); const bridge = await createHarnessBridge({ noServer: true, @@ -486,6 +493,7 @@ export const createHarnessSession = async ( return await import(platform.runner).then((module) => module.default(platform.config, runtimeConfig, { signal, + crashArtifactWriter, } satisfies HarnessPlatformInitOptions), ).then((instance) => { sessionLogger.debug('platform runner initialized'); diff --git a/packages/platform-android/src/__tests__/crash-diagnostics.test.ts b/packages/platform-android/src/__tests__/crash-diagnostics.test.ts new file mode 100644 index 00000000..6f09f7aa --- /dev/null +++ b/packages/platform-android/src/__tests__/crash-diagnostics.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + filterExitInfo, + getBestDropboxArtifact, + parseDropboxOutput, +} from '../crash-diagnostics.js'; + +const javaCrashDropbox = ` +Drop box contents: 1 entries +Max entries: 1000 +======================================== +2026-03-12 10:30:45.123 data_app_crash (text, 500 bytes) +Process: com.harnessplayground +PID: 7777 +Package: com.harnessplayground v1 (1.0) +java.lang.RuntimeException: boom +\tat com.harnessplayground.MainActivity.onCreate(MainActivity.kt:38) +`; + +const nativeCrashDropbox = ` +======================================== +2026-03-12 10:31:10.456 data_app_native_crash (text, 800 bytes) +Process: com.harnessplayground +PID: 8888 +signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdeadbeef +Abort message: 'JNI DETECTED ERROR IN APPLICATION' +backtrace: + #00 pc 00001234 /data/app/lib/libapp.so +`; + +describe('parseDropboxOutput', () => { + it('parses java and native dropbox entries', () => { + const entries = parseDropboxOutput(`${javaCrashDropbox}\n${nativeCrashDropbox}`); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + tag: 'data_app_crash', + pid: 7777, + }); + expect(entries[1]).toMatchObject({ + tag: 'data_app_native_crash', + pid: 8888, + }); + expect(entries[1]?.content).toContain('SIGSEGV'); + }); +}); + +describe('getBestDropboxArtifact', () => { + it('prefers the highest-scored artifact', () => { + const entries = parseDropboxOutput(`${javaCrashDropbox}\n${nativeCrashDropbox}`); + const artifacts = entries.map((entry, index) => ({ + artifactType: + entry.tag === 'data_app_native_crash' + ? ('dropbox-native-crash' as const) + : ('dropbox-crash' as const), + dropboxTag: entry.tag, + occurredAt: Date.now(), + score: index === 0 ? 100 : 300, + summary: entry.content, + pid: entry.pid, + processName: 'com.harnessplayground', + })); + + expect(getBestDropboxArtifact(artifacts)?.dropboxTag).toBe( + 'data_app_native_crash' + ); + }); +}); + +describe('filterExitInfo', () => { + it('returns matching exit-info records for the target package and pid', () => { + const output = ` +ApplicationExitInfo #0: + package=com.harnessplayground pid=7777 realUid=10123 + reason=4 (APP CRASH(NATIVE)) + timestamp=2026-03-12 10:31:10.456 +ApplicationExitInfo #1: + package=com.other.app pid=9999 realUid=10199 + reason=10 (USER REQUESTED) +`; + + const filtered = filterExitInfo({ + output, + bundleId: 'com.harnessplayground', + pid: 7777, + }); + + expect(filtered).toContain('com.harnessplayground pid=7777'); + expect(filtered).not.toContain('com.other.app'); + }); + + it('returns null when no exit-info records exist', () => { + expect( + filterExitInfo({ + output: 'No exit info records for com.harnessplayground', + bundleId: 'com.harnessplayground', + }) + ).toBeNull(); + }); +}); diff --git a/packages/platform-android/src/__tests__/crash-reporter.test.ts b/packages/platform-android/src/__tests__/crash-reporter.test.ts new file mode 100644 index 00000000..c64116b6 --- /dev/null +++ b/packages/platform-android/src/__tests__/crash-reporter.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createAndroidCrashReporter } from '../crash-reporter.js'; + +const javaCrashDropbox = ` +======================================== +2026-03-12 10:30:45.123 data_app_crash (text, 500 bytes) +Process: com.harnessplayground +PID: 7777 +Package: com.harnessplayground v1 (1.0) +java.lang.RuntimeException: boom +\tat com.harnessplayground.MainActivity.onCreate(MainActivity.kt:38) +`; + +const nativeCrashDropbox = ` +======================================== +2026-03-12 10:31:10.456 data_app_native_crash (text, 800 bytes) +Process: com.harnessplayground +PID: 8888 +signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdeadbeef +Abort message: 'JNI DETECTED ERROR IN APPLICATION' +backtrace: + #00 pc 00001234 /data/app/lib/libapp.so +`; + +describe('createAndroidCrashReporter', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('persists the current logcat crash block with the test file path', async () => { + vi.useFakeTimers(); + const persistArtifact = vi.fn( + () => '/tmp/.harness/crash-reports/logcat.txt' + ); + const reporter = createAndroidCrashReporter({ + bundleId: 'com.harnessplayground', + crashArtifactWriter: { + runTimestamp: '2026-03-12T11-35-08-000Z', + persistArtifact, + }, + getLogs: () => [ + { line: 'ordinary crash-buffer line', occurredAt: 1_000 }, + { line: '--------- beginning of crash', occurredAt: 1_100 }, + { + line: 'Process: com.harnessplayground, PID: 7777', + occurredAt: 1_100, + }, + { line: 'FATAL EXCEPTION: main', occurredAt: 1_100 }, + ], + }); + + const detailsPromise = reporter.getCrashDetails({ + occurredAt: 1_100, + pid: 7777, + testFilePath: '/test/native-crash.test.ts', + }); + await vi.advanceTimersByTimeAsync(100); + const details = await detailsPromise; + + expect(details).toMatchObject({ + artifactType: 'logcat', + artifactPath: '/tmp/.harness/crash-reports/logcat.txt', + pid: 7777, + processName: 'com.harnessplayground', + }); + expect(persistArtifact).toHaveBeenCalledWith({ + artifactKind: 'logcat', + testFilePath: '/test/native-crash.test.ts', + source: { + kind: 'text', + fileName: 'logcat.txt', + text: [ + '--------- beginning of crash', + 'Process: com.harnessplayground, PID: 7777', + 'FATAL EXCEPTION: main', + '', + ].join('\n'), + }, + }); + }); + + it('adds dropbox artifacts as enrichment when logcat evidence is available', async () => { + vi.useFakeTimers(); + const persistArtifact = vi + .fn() + .mockImplementation(({ artifactKind }: { artifactKind: string }) => { + return `/tmp/.harness/crash-reports/${artifactKind}.txt`; + }); + const reporter = createAndroidCrashReporter({ + bundleId: 'com.harnessplayground', + crashArtifactWriter: { + runTimestamp: '2026-03-12T11-35-08-000Z', + persistArtifact, + }, + getLogs: () => [ + { line: '--------- beginning of crash', occurredAt: 1_100 }, + { + line: 'Process: com.harnessplayground, PID: 7777', + occurredAt: 1_100, + }, + { line: 'FATAL EXCEPTION: main', occurredAt: 1_100 }, + ], + getDropboxOutput: async () => javaCrashDropbox, + getExitInfo: async () => + 'ApplicationExitInfo #0:\n package=com.harnessplayground pid=7777 reason=4 (APP CRASH(NATIVE))', + }); + + const detailsPromise = reporter.getCrashDetails({ + occurredAt: 1_100, + pid: 7777, + testFilePath: '/test/native-crash.test.ts', + }); + await vi.advanceTimersByTimeAsync(100); + const details = await detailsPromise; + + expect(details).toMatchObject({ + artifactType: 'logcat', + artifactPath: '/tmp/.harness/crash-reports/logcat.txt', + }); + expect(details?.enrichmentArtifacts).toEqual( + expect.arrayContaining([ + { + artifactType: 'dropbox-crash', + artifactPath: '/tmp/.harness/crash-reports/dropbox-crash.txt', + }, + { + artifactType: 'exit-info', + artifactPath: '/tmp/.harness/crash-reports/exit-info.txt', + }, + ]) + ); + }); + + it('falls back to native dropbox evidence when logcat is empty', async () => { + vi.useFakeTimers(); + const persistArtifact = vi + .fn() + .mockImplementation(({ artifactKind }: { artifactKind: string }) => { + return `/tmp/.harness/crash-reports/${artifactKind}.txt`; + }); + const reporter = createAndroidCrashReporter({ + bundleId: 'com.harnessplayground', + crashArtifactWriter: { + runTimestamp: '2026-03-12T11-35-08-000Z', + persistArtifact, + }, + getLogs: () => [], + getDropboxOutput: async () => nativeCrashDropbox, + }); + + const detailsPromise = reporter.getCrashDetails({ + occurredAt: 1_100, + pid: 8888, + testFilePath: '/test/native-crash.test.ts', + }); + await vi.advanceTimersByTimeAsync(100); + const details = await detailsPromise; + + expect(details).toMatchObject({ + artifactType: 'dropbox-native-crash', + artifactPath: '/tmp/.harness/crash-reports/dropbox-native-crash.txt', + pid: 8888, + signal: 'SIGSEGV', + processName: 'com.harnessplayground', + }); + }); +}); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index bf1cc273..87e192af 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -791,6 +791,45 @@ export const startLogcat = ( stderr: 'pipe', }); +export const DROPBOX_CRASH_TAGS = [ + 'data_app_crash', + 'data_app_native_crash', +] as const; + +export const getDropboxPrint = async ( + adbId: string, + tags: readonly string[] = DROPBOX_CRASH_TAGS, +): Promise => { + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'dumpsys', + 'dropbox', + '--print', + ...tags, + ]); + + return stdout; +}; + +export const getActivityExitInfo = async ( + adbId: string, + bundleId: string, +): Promise => { + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'dumpsys', + 'activity', + 'exit-info', + bundleId, + ]); + + return stdout; +}; + export const getAvds = async (): Promise => { try { const emulatorBinaryPath = await ensureEmulatorInstalled(); diff --git a/packages/platform-android/src/app-session.ts b/packages/platform-android/src/app-session.ts index 23a13f29..562f6f35 100644 --- a/packages/platform-android/src/app-session.ts +++ b/packages/platform-android/src/app-session.ts @@ -3,18 +3,19 @@ import { createBoundedLogBuffer, type AppSession, type AppSessionState, + type CrashArtifactWriter, } from '@react-native-harness/platforms'; import { escapeRegExp, logger, type Subprocess, } from '@react-native-harness/tools'; +import { createAndroidCrashReporter } from './crash-reporter.js'; const androidAppSessionLogger = logger.child('android-app-session'); const APP_EXIT_POLL_INTERVAL_MS = 1000; -const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const getLogcatArgs = (appUid: number, fromTime: string) => [ @@ -36,8 +37,10 @@ const getStartProcPattern = (bundleId: string) => const getProcessDiedPattern = (bundleId: string) => new RegExp( - `Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, - 'i', + `Process\\s+${escapeRegExp( + bundleId + )}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, + 'i' ); const getObservedPid = (line: string, bundleId: string): number | undefined => { @@ -54,7 +57,8 @@ const isCrashSignal = (line: string, bundleId: string): boolean => { getProcessPattern(bundleId).test(line) || new RegExp(`>>>\\s*${escapeRegExp(bundleId)}\\s*<<<`).test(line) || getProcessDiedPattern(bundleId).test(line) || - (line.includes(bundleId) && /fatal|crash|signal 11|signal 6|backtrace/i.test(line)) + (line.includes(bundleId) && + /fatal|crash|signal 11|signal 6|backtrace/i.test(line)) ); }; @@ -67,7 +71,7 @@ const stopSubprocess = async (child: Subprocess) => { }; const isExitedState = ( - state: AppSessionState, + state: AppSessionState ): state is Extract => state.status === 'exited'; @@ -79,6 +83,9 @@ type CreateAndroidAppSessionOptions = { getAppPid: () => Promise; getLogcatTimestamp: () => Promise; startLogcat: (args: readonly string[]) => Subprocess; + getDropboxOutput?: () => Promise; + getExitInfo?: () => Promise; + crashArtifactWriter?: CrashArtifactWriter; }; export const createAndroidAppSession = async ({ @@ -89,6 +96,9 @@ export const createAndroidAppSession = async ({ getAppPid, getLogcatTimestamp, startLogcat, + getDropboxOutput, + getExitInfo, + crashArtifactWriter, }: CreateAndroidAppSessionOptions): Promise => { const emitter = createAppSessionEmitter(); const logBuffer = createBoundedLogBuffer(); @@ -111,7 +121,8 @@ export const createAndroidAppSession = async ({ hasObservedProcess = true; } - state = pid === undefined ? { status: 'running' } : { status: 'running', pid }; + state = + pid === undefined ? { status: 'running' } : { status: 'running', pid }; }; const scheduleExitNotification = () => { @@ -152,7 +163,7 @@ export const createAndroidAppSession = async ({ const setExited = ( reason: 'observed-exit' | 'process-gone', - pid?: number, + pid?: number ) => { if (disposed || state.status === 'disposed' || state.status === 'exited') { return; @@ -181,7 +192,16 @@ export const createAndroidAppSession = async ({ }; const logcatTimestamp = await getLogcatTimestamp(); + const sessionStartedAt = Date.now(); const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp)); + const crashReporter = createAndroidCrashReporter({ + bundleId, + crashArtifactWriter, + getLogs: () => logBuffer.getLogs(), + getDropboxOutput, + getExitInfo, + minOccurredAt: sessionStartedAt, + }); const logTask = (async () => { try { @@ -273,6 +293,7 @@ export const createAndroidAppSession = async ({ }, getState: async () => state, getLogs: () => logBuffer.getLogs(), + getCrashDetails: crashReporter.getCrashDetails, addListener: emitter.addListener, removeListener: emitter.removeListener, }; diff --git a/packages/platform-android/src/crash-diagnostics.ts b/packages/platform-android/src/crash-diagnostics.ts new file mode 100644 index 00000000..77412f11 --- /dev/null +++ b/packages/platform-android/src/crash-diagnostics.ts @@ -0,0 +1,475 @@ +import type { + AppCrashDetails, + CrashArtifactWriter, + CrashDetailsLookupOptions, + CrashEnrichmentArtifact, +} from '@react-native-harness/platforms'; +import { escapeRegExp, logger } from '@react-native-harness/tools'; +import { androidCrashParser } from './crash-parser.js'; + +const crashDiagnosticsLogger = logger.child('android-crash-diagnostics'); + +const DROPBOX_ENTRY_HEADER = + /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})?) (\S+) \(text, \d+ bytes\)/; +const DROPBOX_POLL_INTERVAL_MS = 1500; +const DROPBOX_WAIT_TIMEOUT_MS = 10000; + +export const DROPBOX_CRASH_TAGS = [ + 'data_app_crash', + 'data_app_native_crash', +] as const; + +export type DropboxEntry = { + tag: string; + timestamp: string; + content: string; + pid?: number; + processName?: string; +}; + +export type DropboxCrashArtifact = AppCrashDetails & { + artifactType: 'dropbox-crash' | 'dropbox-native-crash'; + dropboxTag: string; + occurredAt: number; + score?: number; +}; + +type CollectDropboxArtifactsOptions = { + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; + getDropboxOutput: () => Promise; + minOccurredAt?: number; +}; + +type WaitForDropboxArtifactsOptions = CollectDropboxArtifactsOptions & + CrashDetailsLookupOptions; + +const getDropboxArtifactType = ( + tag: string +): 'dropbox-crash' | 'dropbox-native-crash' => + tag === 'data_app_native_crash' ? 'dropbox-native-crash' : 'dropbox-crash'; + +const getDropboxFileName = (tag: string) => + tag === 'data_app_native_crash' + ? 'dropbox-native-crash.txt' + : 'dropbox-crash.txt'; + +const parseDropboxTimestamp = (timestamp: string): number => { + const match = timestamp.match( + /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?$/ + ); + + if (!match) { + return 0; + } + + const [, year, month, day, hour, minute, second, millis = '0'] = match; + + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + Number(millis) + ); +}; + +const extractDropboxPid = (content: string): number | undefined => { + const match = + content.match(/^PID:\s*(\d+)/m) ?? content.match(/\bpid:\s*(\d+)/im); + + return match ? Number(match[1]) : undefined; +}; + +const extractDropboxProcessName = ( + content: string, + bundleId: string +): string | undefined => { + const processMatch = content.match(/^Process:\s*(\S+)/m); + const packageMatch = content.match(/^Package:\s*(\S+)/m); + + if (processMatch?.[1] === bundleId) { + return processMatch[1]; + } + + if (packageMatch?.[1]?.startsWith(bundleId)) { + return bundleId; + } + + return content.includes(bundleId) ? bundleId : undefined; +}; + +export const parseDropboxOutput = (output: string): DropboxEntry[] => { + if (output.trim() === '') { + return []; + } + + const sections = output.split( + /^={10,}\s*$/m + ); + const entries: DropboxEntry[] = []; + + for (const section of sections) { + const trimmedSection = section.trim(); + + if (trimmedSection === '') { + continue; + } + + const lines = trimmedSection.split('\n'); + const headerIndex = lines.findIndex((line) => + DROPBOX_ENTRY_HEADER.test(line.trim()) + ); + + if (headerIndex === -1) { + continue; + } + + const headerMatch = lines[headerIndex].trim().match(DROPBOX_ENTRY_HEADER); + + if (!headerMatch) { + continue; + } + + const [, timestamp, tag] = headerMatch; + const content = lines.slice(headerIndex + 1).join('\n').trim(); + + if (content === '') { + continue; + } + + entries.push({ + tag, + timestamp, + content, + pid: extractDropboxPid(content), + }); + } + + return entries; +}; + +const matchesDropboxEntry = ({ + entry, + bundleId, + pid, +}: { + entry: DropboxEntry; + bundleId: string; + pid?: number; +}) => { + const processName = extractDropboxProcessName(entry.content, bundleId); + + if (!processName) { + return false; + } + + if (pid !== undefined && entry.pid !== undefined && entry.pid !== pid) { + return false; + } + + return true; +}; + +const scoreDropboxEntry = ({ + entry, + bundleId, + pid, + occurredAt, +}: { + entry: DropboxEntry; + bundleId: string; + pid?: number; + occurredAt: number; +}): number => { + let score = 0; + + if (extractDropboxProcessName(entry.content, bundleId) === bundleId) { + score += 100; + } + + if (pid !== undefined && entry.pid === pid) { + score += 200; + } + + const entryTimestamp = parseDropboxTimestamp(entry.timestamp); + + if (entryTimestamp > 0) { + score -= Math.min(Math.abs(entryTimestamp - occurredAt) / 1000, 300); + } + + if (entry.tag === 'data_app_native_crash') { + score += 25; + } + + return score; +}; + +const toDropboxCrashArtifact = ({ + entry, + bundleId, + pid, + occurredAt, + lookup, +}: { + entry: DropboxEntry; + bundleId: string; + pid?: number; + occurredAt: number; + lookup?: CrashDetailsLookupOptions; +}): DropboxCrashArtifact => { + const parsed = androidCrashParser.parse({ + contents: entry.content, + bundleId, + pid: pid ?? entry.pid, + }); + const artifactType = getDropboxArtifactType(entry.tag); + + return { + ...parsed, + source: 'logs', + summary: entry.content, + rawLines: entry.content.split(/\r?\n/), + artifactType, + dropboxTag: entry.tag, + occurredAt, + score: scoreDropboxEntry({ + entry, + bundleId, + pid: lookup?.pid ?? pid, + occurredAt: lookup?.occurredAt ?? occurredAt, + }), + }; +}; + +const getMatchingDropboxEntries = ({ + output, + bundleId, + pid, + occurredAt, + minOccurredAt, +}: { + output: string; + bundleId: string; + pid?: number; + occurredAt: number; + minOccurredAt?: number; +}) => { + const minTimestamp = + minOccurredAt !== undefined ? minOccurredAt - 60_000 : undefined; + + return parseDropboxOutput(output) + .filter((entry) => matchesDropboxEntry({ entry, bundleId, pid })) + .filter((entry) => { + if (minTimestamp === undefined) { + return true; + } + + const entryTimestamp = parseDropboxTimestamp(entry.timestamp); + + return entryTimestamp === 0 || entryTimestamp >= minTimestamp; + }) + .map((entry) => + toDropboxCrashArtifact({ + entry, + bundleId, + pid, + occurredAt, + }) + ) + .sort((left, right) => { + if ((right.score ?? 0) !== (left.score ?? 0)) { + return (right.score ?? 0) - (left.score ?? 0); + } + + return right.occurredAt - left.occurredAt; + }); +}; + +const persistDropboxArtifact = ({ + artifact, + crashArtifactWriter, + testFilePath, +}: { + artifact: DropboxCrashArtifact; + crashArtifactWriter?: CrashArtifactWriter; + testFilePath?: string; +}): DropboxCrashArtifact => { + if (!crashArtifactWriter) { + return artifact; + } + + return { + ...artifact, + artifactPath: crashArtifactWriter.persistArtifact({ + artifactKind: artifact.artifactType, + testFilePath, + source: { + kind: 'text', + fileName: getDropboxFileName(artifact.dropboxTag), + text: `${artifact.summary ?? ''}\n`, + }, + }), + }; +}; + +export const collectDropboxArtifacts = async ({ + bundleId, + crashArtifactWriter, + getDropboxOutput, + minOccurredAt, + ...lookup +}: WaitForDropboxArtifactsOptions): Promise => { + crashDiagnosticsLogger.debug('collecting dropbox crash artifacts: %o', { + bundleId, + pid: lookup.pid, + occurredAt: lookup.occurredAt, + }); + + let output = ''; + + try { + output = await getDropboxOutput(); + } catch (error) { + crashDiagnosticsLogger.debug('failed to read dropbox entries', error); + return []; + } + + return getMatchingDropboxEntries({ + output, + bundleId, + pid: lookup.pid, + occurredAt: lookup.occurredAt, + minOccurredAt, + }).map((artifact) => + persistDropboxArtifact({ + artifact, + crashArtifactWriter, + testFilePath: lookup.testFilePath, + }) + ); +}; + +export const waitForDropboxArtifacts = async ( + options: WaitForDropboxArtifactsOptions +): Promise => { + const deadline = Date.now() + DROPBOX_WAIT_TIMEOUT_MS; + let latestArtifacts: DropboxCrashArtifact[] = []; + + while (Date.now() < deadline) { + latestArtifacts = await collectDropboxArtifacts(options); + + if (latestArtifacts.length > 0) { + return latestArtifacts; + } + + if (Date.now() >= deadline) { + return latestArtifacts; + } + + await new Promise((resolve) => + setTimeout(resolve, DROPBOX_POLL_INTERVAL_MS) + ); + } + + return latestArtifacts; +}; + +export const filterExitInfo = ({ + output, + bundleId, + pid, +}: { + output: string; + bundleId: string; + pid?: number; +}): string | null => { + if (output.trim() === '' || /No exit info records/i.test(output)) { + return null; + } + + const packagePattern = new RegExp( + `\\bpackage=${escapeRegExp(bundleId)}\\b`, + 'i' + ); + + if (!packagePattern.test(output)) { + return null; + } + + const sections = output.split(/(?=ApplicationExitInfo\b)/); + const matchingSections = sections.filter((section) => { + if (!packagePattern.test(section)) { + return false; + } + + if (pid === undefined) { + return true; + } + + return new RegExp(`\\bpid=${pid}\\b`).test(section); + }); + + if (matchingSections.length === 0) { + return packagePattern.test(output) ? output.trim() : null; + } + + return matchingSections.join('\n').trim(); +}; + +export const collectExitInfoArtifact = async ({ + bundleId, + crashArtifactWriter, + getExitInfo, + pid, + testFilePath, +}: { + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; + getExitInfo: () => Promise; + pid?: number; + testFilePath?: string; +}): Promise => { + let output = ''; + + try { + output = await getExitInfo(); + } catch (error) { + crashDiagnosticsLogger.debug('failed to read activity exit-info', error); + return null; + } + + const filtered = filterExitInfo({ output, bundleId, pid }); + + if (!filtered) { + return null; + } + + if (!crashArtifactWriter) { + return { + artifactType: 'exit-info', + artifactPath: filtered, + }; + } + + return { + artifactType: 'exit-info', + artifactPath: crashArtifactWriter.persistArtifact({ + artifactKind: 'exit-info', + testFilePath, + source: { + kind: 'text', + fileName: 'exit-info.txt', + text: `${filtered}\n`, + }, + }), + }; +}; + +export const getBestDropboxArtifact = ( + artifacts: DropboxCrashArtifact[] +): DropboxCrashArtifact | null => + [...artifacts].sort((left, right) => (right.score ?? 0) - (left.score ?? 0))[0] ?? + null; diff --git a/packages/platform-android/src/crash-reporter.ts b/packages/platform-android/src/crash-reporter.ts new file mode 100644 index 00000000..830670ac --- /dev/null +++ b/packages/platform-android/src/crash-reporter.ts @@ -0,0 +1,263 @@ +import type { + AppCrashDetails, + AppSessionLog, + CrashArtifactWriter, + CrashDetailsLookupOptions, + CrashEnrichmentArtifact, +} from '@react-native-harness/platforms'; +import { androidCrashParser } from './crash-parser.js'; +import { + collectExitInfoArtifact, + waitForDropboxArtifacts, + getBestDropboxArtifact, + type DropboxCrashArtifact, +} from './crash-diagnostics.js'; + +const CRASH_ARTIFACT_SETTLE_DELAY_MS = 100; +const CRASH_BLOCK_HEADER = '--------- beginning of crash'; +const CRASH_LOG_WINDOW_MS = 5000; + +const findCrashBlockStart = (lines: string[]) => { + let latestCrashHeaderIndex = -1; + + for (let index = lines.length - 1; index >= 0; index -= 1) { + if ( + /FATAL EXCEPTION:|Process:\s+.+,\s+PID:|>>>\s+.+\s+<<= 0 + ? latestBlockHeaderIndex + : latestCrashHeaderIndex; +}; + +const getCrashBlock = (logs: AppSessionLog[], occurredAt: number) => { + const nearbyLogs = logs.filter( + (log) => Math.abs(log.occurredAt - occurredAt) <= CRASH_LOG_WINDOW_MS + ); + const lines = + nearbyLogs.length > 0 + ? nearbyLogs.map((log) => log.line) + : logs.map((log) => log.line); + const blockStart = findCrashBlockStart(lines); + + return blockStart >= 0 ? lines.slice(blockStart) : lines; +}; + +const hasUsefulLogcatBlock = (rawLines: string[]) => + rawLines.some((line) => + /FATAL EXCEPTION:|Process:\s+.+,\s+PID:|>>>\s+.+\s+<< ({ + ...androidCrashParser.parse({ + contents: rawLines.join('\n'), + bundleId, + pid, + }), + artifactType: 'logcat', + rawLines, +}); + +const persistLogcatArtifact = ({ + details, + crashArtifactWriter, + testFilePath, +}: { + details: AppCrashDetails; + crashArtifactWriter?: CrashArtifactWriter; + testFilePath?: string; +}): AppCrashDetails => { + if (!crashArtifactWriter || !details.rawLines?.length) { + return details; + } + + return { + ...details, + artifactPath: crashArtifactWriter.persistArtifact({ + artifactKind: 'logcat', + testFilePath, + source: { + kind: 'text', + fileName: 'logcat.txt', + text: `${details.rawLines.join('\n')}\n`, + }, + }), + }; +}; + +const getDropboxFallbackDetails = ( + artifact: DropboxCrashArtifact +): AppCrashDetails => ({ + ...artifact, + artifactType: artifact.artifactType, + artifactPath: artifact.artifactPath, +}); + +const getEnrichmentArtifacts = async ({ + bundleId, + crashArtifactWriter, + getDropboxOutput, + getExitInfo, + minOccurredAt, + occurredAt, + pid, + testFilePath, +}: { + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; + getDropboxOutput?: () => Promise; + getExitInfo?: () => Promise; + minOccurredAt?: number; + occurredAt: number; + pid?: number; + testFilePath?: string; +}): Promise<{ + dropboxArtifacts: DropboxCrashArtifact[]; + enrichmentArtifacts: CrashEnrichmentArtifact[]; +}> => { + const dropboxArtifacts = getDropboxOutput + ? await waitForDropboxArtifacts({ + bundleId, + crashArtifactWriter, + getDropboxOutput, + minOccurredAt, + occurredAt, + pid, + testFilePath, + }) + : []; + + const enrichmentArtifacts: CrashEnrichmentArtifact[] = dropboxArtifacts + .filter((artifact) => artifact.artifactPath !== undefined) + .map((artifact) => ({ + artifactType: artifact.artifactType, + artifactPath: artifact.artifactPath as string, + })); + + if (getExitInfo) { + const exitInfoArtifact = await collectExitInfoArtifact({ + bundleId, + crashArtifactWriter, + getExitInfo, + pid, + testFilePath, + }); + + if (exitInfoArtifact?.artifactPath.startsWith('/')) { + enrichmentArtifacts.push({ + artifactType: exitInfoArtifact.artifactType, + artifactPath: exitInfoArtifact.artifactPath, + }); + } + } + + return { dropboxArtifacts, enrichmentArtifacts }; +}; + +export const createAndroidCrashReporter = ({ + bundleId, + crashArtifactWriter, + getLogs, + getDropboxOutput, + getExitInfo, + minOccurredAt, +}: { + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; + getLogs: () => AppSessionLog[]; + getDropboxOutput?: () => Promise; + getExitInfo?: () => Promise; + minOccurredAt?: number; +}) => ({ + getCrashDetails: async ({ + occurredAt, + pid, + testFilePath, + }: CrashDetailsLookupOptions): Promise => { + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) + ); + + const rawLines = getCrashBlock(getLogs(), occurredAt); + const logcatDetails = + rawLines.length > 0 + ? getLogcatCrashDetails({ rawLines, bundleId, pid }) + : null; + + let enrichmentArtifacts: CrashEnrichmentArtifact[] = []; + let dropboxArtifacts: DropboxCrashArtifact[] = []; + + if (getDropboxOutput || getExitInfo) { + ({ dropboxArtifacts, enrichmentArtifacts } = + await getEnrichmentArtifacts({ + bundleId, + crashArtifactWriter, + getDropboxOutput, + getExitInfo, + minOccurredAt, + occurredAt, + pid, + testFilePath, + })); + } + + if (logcatDetails && hasUsefulLogcatBlock(rawLines)) { + return { + ...persistLogcatArtifact({ + details: logcatDetails, + crashArtifactWriter, + testFilePath, + }), + enrichmentArtifacts: + enrichmentArtifacts.length > 0 ? enrichmentArtifacts : undefined, + }; + } + + const dropboxFallback = getBestDropboxArtifact(dropboxArtifacts); + + if (dropboxFallback) { + const primary = getDropboxFallbackDetails(dropboxFallback); + + return { + ...primary, + enrichmentArtifacts: enrichmentArtifacts.filter( + (artifact) => artifact.artifactPath !== primary.artifactPath + ), + }; + } + + if (logcatDetails) { + return { + ...persistLogcatArtifact({ + details: logcatDetails, + crashArtifactWriter, + testFilePath, + }), + enrichmentArtifacts: + enrichmentArtifacts.length > 0 ? enrichmentArtifacts : undefined, + }; + } + + return enrichmentArtifacts.length > 0 + ? { + enrichmentArtifacts, + } + : null; + }, +}); diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index ef79acfe..7b539310 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -66,7 +66,7 @@ const getOptionalHarnessAppPath = (): string | undefined => { const configureAndroidRuntime = async ( adbId: string, config: AndroidPlatformConfig, - harnessConfig: HarnessConfig, + harnessConfig: HarnessConfig ): Promise => { const metroPort = harnessConfig.metroPort; @@ -143,14 +143,14 @@ const prepareCachedAvd = async ({ hasExistingAvd ? 'Recreating incompatible Android emulator %s...' : 'Creating Android emulator %s...', - emulatorName, + emulatorName ); if (hasExistingAvd && !compatibility.compatible) { androidInstanceLogger.debug( 'Android AVD %s is not reusable: %s', emulatorName, - compatibility.reason, + compatibility.reason ); await adb.deleteAvd(emulatorName); } @@ -180,7 +180,7 @@ const prepareCachedAvd = async ({ export const getAndroidEmulatorPlatformInstance = async ( config: AndroidPlatformConfig, harnessConfig: HarnessConfig, - init: HarnessPlatformInitOptions, + init: HarnessPlatformInitOptions ): Promise => { assertAndroidDeviceEmulator(config.device); const permissionsEnabled = harnessConfig.permissions ?? false; @@ -198,7 +198,7 @@ export const getAndroidEmulatorPlatformInstance = async ( androidInstanceLogger.debug( 'resolved Android emulator %s with adb id %s', emulatorConfig.name, - adbId ?? 'not-found', + adbId ?? 'not-found' ); if (!adbId) { @@ -218,7 +218,7 @@ export const getAndroidEmulatorPlatformInstance = async ( logger.info('Creating Android emulator %s...', emulatorName); androidInstanceLogger.debug( 'creating Android AVD %s before startup', - emulatorConfig.name, + emulatorConfig.name ); await recreateAvd({ emulatorConfig }); } else { @@ -227,7 +227,7 @@ export const getAndroidEmulatorPlatformInstance = async ( androidInstanceLogger.debug( 'starting Android emulator %s', - emulatorConfig.name, + emulatorConfig.name ); return startAndWaitForBoot({ emulatorName: emulatorConfig.name, @@ -240,7 +240,7 @@ export const getAndroidEmulatorPlatformInstance = async ( androidInstanceLogger.debug( 'Android emulator %s connected as %s', emulatorConfig.name, - adbId, + adbId ); } @@ -250,7 +250,7 @@ export const getAndroidEmulatorPlatformInstance = async ( androidInstanceLogger.debug( 'waiting for Android emulator %s to finish booting', - adbId, + adbId ); const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); @@ -285,12 +285,15 @@ export const getAndroidEmulatorPlatformInstance = async ( adbId, config.bundleId, config.activityName, - launchOptions, + launchOptions ), stopApp: () => adb.stopApp(adbId, config.bundleId), getAppPid: () => adb.getAppPid(adbId, config.bundleId), getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId), startLogcat: (args) => adb.startLogcat(adbId, args), + getDropboxOutput: () => adb.getDropboxPrint(adbId), + getExitInfo: () => adb.getActivityExitInfo(adbId, config.bundleId), + crashArtifactWriter: init.crashArtifactWriter, }); }, dispose: async () => { @@ -309,6 +312,7 @@ export const getAndroidEmulatorPlatformInstance = async ( export const getAndroidPhysicalDevicePlatformInstance = async ( config: AndroidPlatformConfig, harnessConfig: HarnessConfig, + init?: HarnessPlatformInitOptions ): Promise => { assertAndroidDevicePhysical(config.device); const permissionsEnabled = harnessConfig.permissions ?? false; @@ -324,7 +328,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( if (!isInstalled) { throw new AppNotInstalledError( config.bundleId, - getDeviceName(config.device), + getDeviceName(config.device) ); } @@ -349,12 +353,15 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( adbId, config.bundleId, config.activityName, - launchOptions, + launchOptions ), stopApp: () => adb.stopApp(adbId, config.bundleId), getAppPid: () => adb.getAppPid(adbId, config.bundleId), getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId), startLogcat: (args) => adb.startLogcat(adbId, args), + getDropboxOutput: () => adb.getDropboxPrint(adbId), + getExitInfo: () => adb.getActivityExitInfo(adbId, config.bundleId), + crashArtifactWriter: init?.crashArtifactWriter, }); }, dispose: async () => { diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index 191b0901..0566bea1 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -20,7 +20,7 @@ import { const getAndroidRunner = async ( config: AndroidPlatformConfig, harnessConfig: HarnessConfig, - init: HarnessPlatformInitOptions, + init: HarnessPlatformInitOptions ): Promise => { const parsedConfig = AndroidPlatformConfigSchema.parse(config); @@ -32,11 +32,15 @@ const getAndroidRunner = async ( return getAndroidEmulatorPlatformInstance( parsedConfig, harnessConfig, - init, + init ); } - return getAndroidPhysicalDevicePlatformInstance(parsedConfig, harnessConfig); + return getAndroidPhysicalDevicePlatformInstance( + parsedConfig, + harnessConfig, + init + ); }; export default getAndroidRunner; diff --git a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts index 6aa0cf7d..4394adbe 100644 --- a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts +++ b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts @@ -14,7 +14,7 @@ describe('collectCrashArtifacts', () => { it('collects simulator crash artifacts from simctl diagnose output', async () => { const outputRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-simctl-diagnose-'), + join(tmpdir(), 'rn-harness-simctl-diagnose-') ); const crashPath = join(outputRoot, 'HarnessPlayground.ips'); fs.writeFileSync( @@ -36,14 +36,14 @@ describe('collectCrashArtifacts', () => { }, }), ].join('\n'), - 'utf8', + 'utf8' ); vi.spyOn(simctl, 'diagnose').mockImplementation( async (_udid, outputDir) => { fs.mkdirSync(outputDir, { recursive: true }); fs.copyFileSync(crashPath, join(outputDir, 'HarnessPlayground.ips')); - }, + } ); const artifacts = await collectCrashArtifacts({ @@ -67,7 +67,7 @@ describe('collectCrashArtifacts', () => { it('collects device crash artifacts from systemCrashLogs', async () => { const outputRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-devicectl-crash-logs-'), + join(tmpdir(), 'rn-harness-devicectl-crash-logs-') ); const crashPath = join(outputRoot, 'HarnessPlayground.crash'); fs.writeFileSync( @@ -78,7 +78,7 @@ describe('collectCrashArtifacts', () => { 'Date/Time: 2026-03-12 11:35:08 +0000', 'Exception Type: EXC_CRASH (SIGABRT)', ].join('\n'), - 'utf8', + 'utf8' ); vi.spyOn(devicectl, 'listFiles').mockResolvedValue([ @@ -87,7 +87,7 @@ describe('collectCrashArtifacts', () => { vi.spyOn(devicectl, 'copyFileFrom').mockImplementation( async (_deviceId, options) => { fs.copyFileSync(crashPath, options.destination); - }, + } ); const artifacts = await collectCrashArtifacts({ targetId: 'device-udid', @@ -108,7 +108,7 @@ describe('collectCrashArtifacts', () => { it('persists matched crash artifacts with the provided writer', async () => { const sourceRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-crash-diagnostics-'), + join(tmpdir(), 'rn-harness-crash-diagnostics-') ); const sourcePath = join(sourceRoot, 'HarnessPlayground.ips'); fs.writeFileSync( @@ -130,14 +130,14 @@ describe('collectCrashArtifacts', () => { }, }), ].join('\n'), - 'utf8', + 'utf8' ); vi.spyOn(simctl, 'diagnose').mockImplementation( async (_udid, outputDir) => { fs.mkdirSync(outputDir, { recursive: true }); fs.copyFileSync(sourcePath, join(outputDir, 'HarnessPlayground.ips')); - }, + } ); const writer = createCrashArtifactWriter({ @@ -147,15 +147,22 @@ describe('collectCrashArtifacts', () => { runTimestamp: '2026-03-12T11-35-08-000Z', }); - const artifacts = await collectCrashArtifacts({ - targetId: 'sim-udid', - targetType: 'simulator', - processNames: ['HarnessPlayground'], - bundleId: 'com.harnessplayground', - crashArtifactWriter: writer, - }); + const artifacts = await collectCrashArtifacts( + { + targetId: 'sim-udid', + targetType: 'simulator', + processNames: ['HarnessPlayground'], + bundleId: 'com.harnessplayground', + crashArtifactWriter: writer, + }, + { + occurredAt: Date.parse('2026-03-12T11:35:08.000Z'), + testFilePath: '/test/native crash.test.ts', + } + ); expect(artifacts[0]?.artifactPath).toContain('/.harness/crash-reports/'); + expect(artifacts[0]?.artifactPath).toContain('test-native-crash.test.ts'); expect(fs.existsSync(artifacts[0]?.artifactPath ?? '')).toBe(true); }); }); diff --git a/packages/platform-ios/src/app-session.ts b/packages/platform-ios/src/app-session.ts index 94dfc1bb..170e3631 100644 --- a/packages/platform-ios/src/app-session.ts +++ b/packages/platform-ios/src/app-session.ts @@ -6,6 +6,7 @@ import { type AppleAppLaunchOptions, } from '@react-native-harness/platforms'; import { logger, type Subprocess } from '@react-native-harness/tools'; +import type { IosCrashReporter } from './crash-reporter.js'; const iosAppSessionLogger = logger.child('ios-app-session'); const APP_EXIT_POLL_INTERVAL_MS = 1000; @@ -15,15 +16,16 @@ type CreateIosAppSessionOptions = { launch: () => Subprocess; stopApp: () => Promise; isAppRunning: () => Promise; + crashReporter?: IosCrashReporter; }; -const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const createIosAppSession = async ({ launch, stopApp, isAppRunning, + crashReporter, }: CreateIosAppSessionOptions): Promise => { const emitter = createAppSessionEmitter(); const logBuffer = createBoundedLogBuffer(); @@ -87,7 +89,7 @@ export const createIosAppSession = async ({ const launchSettled = await Promise.race([ launchProcess.then( () => 'settled' as const, - () => 'settled' as const, + () => 'settled' as const ), sleep(LAUNCH_FAILURE_SETTLE_MS).then(() => 'running' as const), ]); @@ -123,6 +125,7 @@ export const createIosAppSession = async ({ }, getState: async () => state, getLogs: () => logBuffer.getLogs(), + getCrashDetails: crashReporter?.getCrashDetails, addListener: emitter.addListener, removeListener: emitter.removeListener, }; diff --git a/packages/platform-ios/src/crash-diagnostics.ts b/packages/platform-ios/src/crash-diagnostics.ts index acc86989..fd0b6717 100644 --- a/packages/platform-ios/src/crash-diagnostics.ts +++ b/packages/platform-ios/src/crash-diagnostics.ts @@ -194,6 +194,7 @@ const parseCrashArtifacts = ({ kind: 'file', path, }, + testFilePath: lookup?.testFilePath, }) : path; @@ -208,7 +209,7 @@ const parseCrashArtifacts = ({ return artifact; }) .filter((artifact): artifact is DiagnosedCrashArtifact => - Boolean(artifact), + Boolean(artifact) ); return candidates.sort((left, right) => { @@ -220,10 +221,23 @@ const parseCrashArtifacts = ({ }); }; -const collectSimulatorCrashArtifacts = async ({ - targetId, - ...options -}: CollectSimulatorCrashArtifactsOptions) => { +const collectSimulatorCrashArtifacts = async ( + { targetId, ...options }: CollectSimulatorCrashArtifactsOptions, + lookup?: CrashDetailsLookupOptions +) => { + const diagnosticReportArtifacts = collectCrashArtifactsFromDiagnosticReports( + { + ...options, + targetId, + targetType: 'simulator', + }, + lookup + ); + + if (diagnosticReportArtifacts.length > 0) { + return diagnosticReportArtifacts; + } + const outputDir = createTempDirectory('rn-harness-simctl-diagnose'); try { @@ -231,6 +245,7 @@ const collectSimulatorCrashArtifacts = async ({ return parseCrashArtifacts({ rootDir: outputDir, options: { ...options, targetId, targetType: 'simulator' }, + lookup, }); } finally { fs.rmSync(outputDir, { recursive: true, force: true }); @@ -239,12 +254,13 @@ const collectSimulatorCrashArtifacts = async ({ const collectCrashArtifactsFromDiagnosticReports = ( options: CollectCrashArtifactsOptions, + lookup?: CrashDetailsLookupOptions ): DiagnosedCrashArtifact[] => { const diagnosticReportsDir = join( homedir(), 'Library', 'Logs', - 'DiagnosticReports', + 'DiagnosticReports' ); if (!fs.existsSync(diagnosticReportsDir)) { @@ -255,7 +271,7 @@ const collectCrashArtifactsFromDiagnosticReports = ( .readdirSync(diagnosticReportsDir) .filter((entry) => entry.endsWith('.ips')) .filter((entry) => - options.processNames.some((name) => entry.startsWith(`${name}-`)), + options.processNames.some((name) => entry.startsWith(`${name}-`)) ); const artifacts: DiagnosedCrashArtifact[] = []; @@ -269,6 +285,14 @@ const collectCrashArtifactsFromDiagnosticReports = ( continue; } + if ( + options.targetType === 'simulator' && + (parsed.targetId !== options.targetId || + !contents.includes(options.targetId)) + ) { + continue; + } + if ( options.minOccurredAt !== undefined && parsed.occurredAt < options.minOccurredAt @@ -280,6 +304,7 @@ const collectCrashArtifactsFromDiagnosticReports = ( ? options.crashArtifactWriter.persistArtifact({ artifactKind: 'ios-crash-report', source: { kind: 'file', path }, + testFilePath: lookup?.testFilePath, }) : path; @@ -290,7 +315,7 @@ const collectCrashArtifactsFromDiagnosticReports = ( occurredAt: parsed.occurredAt, }; - artifact.score = scoreCrashArtifact({ artifact, options }); + artifact.score = scoreCrashArtifact({ artifact, options, lookup }); artifacts.push(artifact); } @@ -303,13 +328,16 @@ const collectCrashArtifactsFromDiagnosticReports = ( }); }; -const collectPhysicalCrashArtifacts = async ({ - targetId, - processNames, - bundleId, - crashArtifactWriter, - minOccurredAt, -}: CollectPhysicalCrashArtifactsOptions) => { +const collectPhysicalCrashArtifacts = async ( + { + targetId, + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, + }: CollectPhysicalCrashArtifactsOptions, + lookup?: CrashDetailsLookupOptions +) => { const crashLogsDir = createTempDirectory('rn-harness-devicectl-crash-logs'); try { @@ -318,7 +346,7 @@ const collectPhysicalCrashArtifacts = async ({ recursive: true, }); const filteredCrashLogPaths = remoteCrashLogPaths.filter((remotePath) => - processNames.some((processName) => remotePath.includes(processName)), + processNames.some((processName) => remotePath.includes(processName)) ); if (filteredCrashLogPaths.length > 0) { @@ -338,6 +366,7 @@ const collectPhysicalCrashArtifacts = async ({ const copiedArtifacts = parseCrashArtifacts({ rootDir: crashLogsDir, + lookup, options: { targetId, targetType: 'device', @@ -356,18 +385,22 @@ const collectPhysicalCrashArtifacts = async ({ fs.rmSync(crashLogsDir, { recursive: true, force: true }); } - return collectCrashArtifactsFromDiagnosticReports({ - targetId, - targetType: 'device', - processNames, - bundleId, - crashArtifactWriter, - minOccurredAt, - }); + return collectCrashArtifactsFromDiagnosticReports( + { + targetId, + targetType: 'device', + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, + }, + lookup + ); }; export const collectCrashArtifacts = async ( options: CollectCrashArtifactsOptions, + lookup?: CrashDetailsLookupOptions ): Promise => { crashDiagnosticsLogger.debug('collecting crash artifacts: %o', { targetId: options.targetId, @@ -377,10 +410,10 @@ export const collectCrashArtifacts = async ( }); if (options.targetType === 'simulator') { - return collectSimulatorCrashArtifacts(options); + return collectSimulatorCrashArtifacts(options, lookup); } - return collectPhysicalCrashArtifacts(options); + return collectPhysicalCrashArtifacts(options, lookup); }; export const waitForCrashArtifact = async ({ @@ -393,7 +426,7 @@ export const waitForCrashArtifact = async ({ let fallbackArtifact = getFallbackArtifact(); while (Date.now() < deadline) { - const artifacts = await collectCrashArtifacts(options); + const artifacts = await collectCrashArtifacts(options, lookup); for (const artifact of artifacts) { recordArtifact(artifact); @@ -416,7 +449,7 @@ export const waitForCrashArtifact = async ({ } await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS), + setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS) ); } diff --git a/packages/platform-ios/src/crash-reporter.ts b/packages/platform-ios/src/crash-reporter.ts new file mode 100644 index 00000000..b181e42e --- /dev/null +++ b/packages/platform-ios/src/crash-reporter.ts @@ -0,0 +1,60 @@ +import type { + AppCrashDetails, + CrashArtifactWriter, + CrashDetailsLookupOptions, +} from '@react-native-harness/platforms'; +import { waitForCrashArtifact } from './crash-diagnostics.js'; + +const CRASH_ARTIFACT_SETTLE_DELAY_MS = 300; + +export type IosCrashReporter = { + getCrashDetails: ( + options: CrashDetailsLookupOptions + ) => Promise; +}; + +export const getIosProcessNames = ( + ...names: Array +) => [...new Set(names.filter((name): name is string => Boolean(name)))]; + +export const createIosCrashReporter = ({ + targetId, + targetType, + bundleId, + processNames, + minOccurredAt, + crashArtifactWriter, +}: { + targetId: string; + targetType: 'simulator' | 'device'; + bundleId: string; + processNames: string[]; + minOccurredAt: number; + crashArtifactWriter?: CrashArtifactWriter; +}): IosCrashReporter => { + const recordedArtifacts: AppCrashDetails[] = []; + + return { + getCrashDetails: async (lookup: CrashDetailsLookupOptions) => { + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) + ); + + return await waitForCrashArtifact({ + lookup, + options: { + targetId, + targetType, + bundleId, + processNames, + minOccurredAt, + crashArtifactWriter, + }, + getFallbackArtifact: () => recordedArtifacts.at(-1) ?? null, + recordArtifact: (artifact) => { + recordedArtifacts.push(artifact); + }, + }); + }, + }; +}; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index ec3a44b1..5279b310 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -22,8 +22,15 @@ import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; import { createXCTestAgentController } from './xctest-agent.js'; import { createPermissionPromptAutoAcceptCapability } from './xctest-agent-capabilities.js'; -import { collectNativeCoverage, cleanProfrawDir } from './coverage-collector.js'; +import { + collectNativeCoverage, + cleanProfrawDir, +} from './coverage-collector.js'; import { createIosAppSession } from './app-session.js'; +import { + createIosCrashReporter, + getIosProcessNames, +} from './crash-reporter.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -44,7 +51,7 @@ const getHarnessAppPath = (): string => { export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, harnessConfig: HarnessConfig, - init: HarnessPlatformInitOptions, + init: HarnessPlatformInitOptions ): Promise => { assertAppleDeviceSimulator(config.device); const permissionsEnabled = harnessConfig.permissions ?? false; @@ -55,7 +62,7 @@ export const getAppleSimulatorPlatformInstance = async ( const udid = await simctl.getSimulatorId( config.device.name, - config.device.systemVersion, + config.device.systemVersion ); if (!udid) { @@ -68,7 +75,7 @@ export const getAppleSimulatorPlatformInstance = async ( iosInstanceLogger.debug( 'resolved iOS simulator %s with status %s', udid, - simulatorStatus, + simulatorStatus ); if ( @@ -79,7 +86,7 @@ export const getAppleSimulatorPlatformInstance = async ( iosInstanceLogger.debug( 'booting iOS simulator %s from status %s', udid, - simulatorStatus, + simulatorStatus ); await simctl.bootSimulator(udid); startedByHarness = true; @@ -90,14 +97,14 @@ export const getAppleSimulatorPlatformInstance = async ( } else if (simctl.isBootingSimulatorStatus(simulatorStatus)) { logger.info( 'Waiting for iOS simulator %s to finish booting...', - config.device.name, + config.device.name ); } if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { iosInstanceLogger.debug( 'waiting for iOS simulator %s to finish booting', - udid, + udid ); await simctl.waitForBoot(udid, init.signal); } @@ -112,7 +119,7 @@ export const getAppleSimulatorPlatformInstance = async ( await simctl.applyHarnessJsLocationOverride( udid, config.bundleId, - `localhost:${harnessConfig.metroPort}`, + `localhost:${harnessConfig.metroPort}` ); const xctestAgent = permissionsEnabled @@ -146,12 +153,28 @@ export const getAppleSimulatorPlatformInstance = async ( const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? config.appLaunchOptions; + const appInfo = await simctl.getAppInfo(udid, config.bundleId); + const processNames = getIosProcessNames( + appInfo?.CFBundleExecutable, + appInfo?.CFBundleName, + appInfo?.CFBundleDisplayName, + config.bundleId + ); + const crashReporter = createIosCrashReporter({ + targetId: udid, + targetType: 'simulator', + bundleId: config.bundleId, + processNames, + minOccurredAt: Date.now(), + crashArtifactWriter: init.crashArtifactWriter, + }); return await createIosAppSession({ launch: () => simctl.launchAppProcess(udid, config.bundleId, launchOptions), stopApp: () => simctl.stopApp(udid, config.bundleId), isAppRunning: () => simctl.isAppRunning(udid, config.bundleId), + crashReporter, }); }, dispose: async () => { @@ -178,13 +201,14 @@ export const getAppleSimulatorPlatformInstance = async ( export const getApplePhysicalDevicePlatformInstance = async ( config: ApplePlatformConfig, harnessConfig: HarnessConfig, + init?: HarnessPlatformInitOptions ): Promise => { assertAppleDevicePhysical(config.device); const permissionsEnabled = harnessConfig.permissions ?? false; if (harnessConfig.metroPort !== DEFAULT_METRO_PORT) { throw new Error( - `Custom Metro port ${harnessConfig.metroPort} is not supported on physical iOS devices. Physical devices always connect to port ${DEFAULT_METRO_PORT}.`, + `Custom Metro port ${harnessConfig.metroPort} is not supported on physical iOS devices. Physical devices always connect to port ${DEFAULT_METRO_PORT}.` ); } @@ -201,21 +225,22 @@ export const getApplePhysicalDevicePlatformInstance = async ( if (!isAvailable) { throw new AppNotInstalledError( config.bundleId, - getDeviceName(config.device), + getDeviceName(config.device) ); } - const xctestAgent = permissionsEnabled && config.device.codeSign - ? createXCTestAgentController({ - appBundleId: config.bundleId, - target: { - kind: 'device', - id: device.hardwareProperties.udid, - codeSign: config.device.codeSign, - }, - capabilities: [createPermissionPromptAutoAcceptCapability()], - }) - : null; + const xctestAgent = + permissionsEnabled && config.device.codeSign + ? createXCTestAgentController({ + appBundleId: config.bundleId, + target: { + kind: 'device', + id: device.hardwareProperties.udid, + codeSign: config.device.codeSign, + }, + capabilities: [createPermissionPromptAutoAcceptCapability()], + }) + : null; if (xctestAgent) { let agentStarted = false; @@ -229,7 +254,7 @@ export const getApplePhysicalDevicePlatformInstance = async ( } } else if (permissionsEnabled) { iosInstanceLogger.info( - 'Skipping XCTest agent for physical device (no codeSign config provided)', + 'Skipping XCTest agent for physical device (no codeSign config provided)' ); } @@ -239,12 +264,23 @@ export const getApplePhysicalDevicePlatformInstance = async ( const launchOptions = (options as typeof config.appLaunchOptions | undefined) ?? config.appLaunchOptions; + const appInfo = await devicectl.getAppInfo(deviceId, config.bundleId); + const processNames = getIosProcessNames(appInfo?.name, config.bundleId); + const crashReporter = createIosCrashReporter({ + targetId: deviceId, + targetType: 'device', + bundleId: config.bundleId, + processNames, + minOccurredAt: Date.now(), + crashArtifactWriter: init?.crashArtifactWriter, + }); return await createIosAppSession({ launch: () => devicectl.launchAppProcess(deviceId, config.bundleId, launchOptions), stopApp: () => devicectl.stopApp(deviceId, config.bundleId), isAppRunning: () => devicectl.isAppRunning(deviceId, config.bundleId), + crashReporter, }); }, dispose: async () => { diff --git a/packages/platform-ios/src/runner.ts b/packages/platform-ios/src/runner.ts index 59b464df..39bf8f3e 100644 --- a/packages/platform-ios/src/runner.ts +++ b/packages/platform-ios/src/runner.ts @@ -24,7 +24,11 @@ const getAppleRunner = async ( return getAppleSimulatorPlatformInstance(parsedConfig, harnessConfig, init); } - return getApplePhysicalDevicePlatformInstance(parsedConfig, harnessConfig); + return getApplePhysicalDevicePlatformInstance( + parsedConfig, + harnessConfig, + init + ); }; export default getAppleRunner; diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 1f7e36fa..1182f3aa 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -14,7 +14,9 @@ export type { AppMonitorEvent, AppMonitorListener, AppLaunchOptions, + CrashArtifactKind, CrashDetailsLookupOptions, + CrashEnrichmentArtifact, CrashArtifactSource, CrashArtifactWriter, CreateAppMonitorOptions, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index fb5c294b..00fde7c7 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -1,3 +1,18 @@ +export type CrashArtifactKind = + | 'logcat' + | 'ios-crash-report' + | 'dropbox-crash' + | 'dropbox-native-crash' + | 'exit-info'; + +export type CrashEnrichmentArtifact = { + artifactType: Exclude< + CrashArtifactKind, + 'logcat' | 'ios-crash-report' + >; + artifactPath: string; +}; + export type AppCrashDetails = { source?: 'polling' | 'logs' | 'bridge'; summary?: string; @@ -7,8 +22,9 @@ export type AppCrashDetails = { pid?: number; stackTrace?: string[]; rawLines?: string[]; - artifactType?: 'logcat' | 'ios-crash-report'; + artifactType?: CrashArtifactKind; artifactPath?: string; + enrichmentArtifacts?: CrashEnrichmentArtifact[]; }; export type CrashArtifactSource = @@ -27,6 +43,7 @@ export type CrashArtifactWriter = { persistArtifact: (options: { artifactKind: string; source: CrashArtifactSource; + testFilePath?: string; }) => string; }; @@ -38,6 +55,7 @@ export type CrashDetailsLookupOptions = { processName?: string; pid?: number; occurredAt: number; + testFilePath?: string; }; export type AppMonitorEvent = @@ -108,6 +126,9 @@ export type AppSession = { dispose: () => Promise; getState: () => Promise; getLogs: () => AppSessionLog[]; + getCrashDetails?: ( + options: CrashDetailsLookupOptions + ) => Promise; addListener: (listener: AppSessionListener) => void; removeListener: (listener: AppSessionListener) => void; }; @@ -146,6 +167,7 @@ export type HarnessPlatformRunner = { export type HarnessPlatformInitOptions = { signal: AbortSignal; + crashArtifactWriter?: CrashArtifactWriter; }; export type HarnessCliCommandContext = { @@ -156,10 +178,7 @@ export type HarnessCliCommandContext = { export type HarnessCliCommand = { name: string; aliases?: string[]; - run: ( - args: string[], - context: HarnessCliCommandContext - ) => Promise; + run: (args: string[], context: HarnessCliCommandContext) => Promise; }; export type HarnessCliModule = { diff --git a/packages/tools/src/__tests__/crash-artifacts.test.ts b/packages/tools/src/__tests__/crash-artifacts.test.ts index e91a74d1..9bd6804c 100644 --- a/packages/tools/src/__tests__/crash-artifacts.test.ts +++ b/packages/tools/src/__tests__/crash-artifacts.test.ts @@ -59,7 +59,42 @@ describe('createCrashArtifactWriter', () => { }); expect(fs.existsSync(artifactRoot)).toBe(true); - expect(fs.readFileSync(persistedPath, 'utf8')).toContain('RuntimeException'); + expect(fs.readFileSync(persistedPath, 'utf8')).toContain( + 'RuntimeException' + ); + }); + + it('includes the absolute test path when one is provided', () => { + const sourcePath = path.join(rootDir, 'Harness Playground 01.crash'); + const testFilePath = path.join(rootDir, 'tests', 'native crash.test.ts'); + fs.writeFileSync(sourcePath, 'crash data', 'utf8'); + + const writer = createCrashArtifactWriter({ + runnerName: 'ios simulator', + platformId: 'ios', + rootDir, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const persistedPath = writer.persistArtifact({ + artifactKind: 'ios-crash-report', + testFilePath, + source: { + kind: 'file', + path: sourcePath, + }, + }); + + expect(path.basename(persistedPath)).toBe( + `2026-03-12T11-35-08-000Z--ios-simulator--${path + .resolve(testFilePath) + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace( + /^-|-$/g, + '' + )}--ios--ios-crash-report--Harness-Playground-01.crash` + ); }); it('deduplicates repeated persistence requests within one run', () => { @@ -91,4 +126,36 @@ describe('createCrashArtifactWriter', () => { expect(firstPath).toBe(secondPath); expect(fs.readdirSync(rootDir)).toHaveLength(2); }); + + it('keeps artifacts for different test files separate', () => { + const sourcePath = path.join(rootDir, 'duplicate.crash'); + fs.writeFileSync(sourcePath, 'same crash', 'utf8'); + + const writer = createCrashArtifactWriter({ + runnerName: 'ios', + platformId: 'ios', + rootDir, + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const firstPath = writer.persistArtifact({ + artifactKind: 'ios-crash-report', + testFilePath: '/tmp/a.test.ts', + source: { + kind: 'file', + path: sourcePath, + }, + }); + const secondPath = writer.persistArtifact({ + artifactKind: 'ios-crash-report', + testFilePath: '/tmp/b.test.ts', + source: { + kind: 'file', + path: sourcePath, + }, + }); + + expect(firstPath).not.toBe(secondPath); + expect(fs.readdirSync(rootDir)).toHaveLength(3); + }); }); diff --git a/packages/tools/src/crash-artifacts.ts b/packages/tools/src/crash-artifacts.ts index a82d9220..cf5c6dc0 100644 --- a/packages/tools/src/crash-artifacts.ts +++ b/packages/tools/src/crash-artifacts.ts @@ -21,12 +21,14 @@ const getTargetFileName = ({ runnerName, platformId, artifactKind, + testFilePath, source, }: { runTimestamp: string; runnerName: string; platformId: string; artifactKind: string; + testFilePath?: string; source: | { kind: 'file'; @@ -43,6 +45,7 @@ const getTargetFileName = ({ return [ sanitizePathSegment(runTimestamp), sanitizePathSegment(runnerName), + ...(testFilePath ? [sanitizePathSegment(path.resolve(testFilePath))] : []), sanitizePathSegment(platformId), sanitizePathSegment(artifactKind), sanitizePathSegment(originalName), @@ -52,10 +55,12 @@ const getTargetFileName = ({ const getDeduplicationKey = ({ platformId, artifactKind, + testFilePath, source, }: { platformId: string; artifactKind: string; + testFilePath?: string; source: | { kind: 'file'; @@ -68,10 +73,14 @@ const getDeduplicationKey = ({ }; }) => { if (source.kind === 'file') { - return `file:${platformId}:${artifactKind}:${path.resolve(source.path)}`; + return `file:${platformId}:${artifactKind}:${ + testFilePath ?? '' + }:${path.resolve(source.path)}`; } - return `text:${platformId}:${artifactKind}:${source.fileName}:${source.text}`; + return `text:${platformId}:${artifactKind}:${testFilePath ?? ''}:${ + source.fileName + }:${source.text}`; }; export const createCrashArtifactWriter = ({ @@ -91,6 +100,7 @@ export const createCrashArtifactWriter = ({ runTimestamp, persistArtifact: (options: { artifactKind: string; + testFilePath?: string; source: | { kind: 'file'; @@ -105,6 +115,7 @@ export const createCrashArtifactWriter = ({ const deduplicationKey = getDeduplicationKey({ platformId, artifactKind: options.artifactKind, + testFilePath: options.testFilePath, source: options.source, }); const existingPath = persistedArtifacts.get(deduplicationKey); @@ -122,6 +133,7 @@ export const createCrashArtifactWriter = ({ runnerName, platformId, artifactKind: options.artifactKind, + testFilePath: options.testFilePath, source: options.source, }) ); From 5776967bcebc7ea4a7f7eec297af2fa99ef10191 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 10:12:17 +0200 Subject: [PATCH 13/17] Remove unused AppMonitor adapters after AppSession migration. Delete the transitional iOS and Android app-monitor implementations and drop the AppMonitor types from the shared platforms contract now that sessions own launch, logs, exit observation, and crash extraction. --- packages/platform-android/src/app-monitor.ts | 577 ----------------- .../src/__tests__/app-monitor.test.ts | 322 ---------- packages/platform-ios/src/app-monitor.ts | 604 ------------------ packages/platforms/src/index.ts | 4 - packages/platforms/src/types.ts | 43 -- 5 files changed, 1550 deletions(-) delete mode 100644 packages/platform-android/src/app-monitor.ts delete mode 100644 packages/platform-ios/src/__tests__/app-monitor.test.ts delete mode 100644 packages/platform-ios/src/app-monitor.ts diff --git a/packages/platform-android/src/app-monitor.ts b/packages/platform-android/src/app-monitor.ts deleted file mode 100644 index f618ad4d..00000000 --- a/packages/platform-android/src/app-monitor.ts +++ /dev/null @@ -1,577 +0,0 @@ -import { - type AppMonitor, - type AppCrashDetails, - type CrashArtifactWriter, - type CrashDetailsLookupOptions, - type AppMonitorEvent, - type AppMonitorListener, -} from '@react-native-harness/platforms'; -import { - escapeRegExp, - getEmitter, - logger, - SubprocessError, - type Subprocess, -} from '@react-native-harness/tools'; -import * as adb from './adb.js'; -import { androidCrashParser } from './crash-parser.js'; - -const androidAppMonitorLogger = logger.child('android-app-monitor'); - -const getLogcatArgs = (uid: number, fromTime: string) => - [ - 'logcat', - '-v', - 'threadtime', - '-b', - 'crash', - `--uid=${uid}`, - '-T', - fromTime, - ] as const; -const MAX_RECENT_LOG_LINES = 200; -const MAX_RECENT_CRASH_ARTIFACTS = 10; -const CRASH_ARTIFACT_SETTLE_DELAY_MS = 100; - -const startProcPattern = (bundleId: string) => - new RegExp(`Start proc (\\d+):${escapeRegExp(bundleId)}(?:/|\\s)`); - -const processPattern = (bundleId: string) => - new RegExp(`Process:\\s*${escapeRegExp(bundleId)},\\s*PID:\\s*(\\d+)`); - -const nativeCrashPattern = (bundleId: string) => - new RegExp(`>>>\\s*${escapeRegExp(bundleId)}\\s*<<<`); - -const processDiedPattern = (bundleId: string) => - new RegExp( - `Process\\s+${escapeRegExp( - bundleId - )}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, - 'i' - ); - -const getSignal = (line: string) => { - const namedSignalMatch = line.match(/\b(SIG[A-Z0-9]+)\b/); - - if (namedSignalMatch) { - return namedSignalMatch[1]; - } - - const signalNumberMatch = line.match(/signal\s+(\d+)/i); - - if (signalNumberMatch) { - return `signal ${signalNumberMatch[1]}`; - } - - return undefined; -}; - -const getAndroidLogLineCrashDetails = ({ - line, - bundleId, - pid, -}: { - line: string; - bundleId: string; - pid?: number; -}): AppCrashDetails => { - const fatalExceptionMatch = line.match(/FATAL EXCEPTION:\s*(.+)$/i); - const processMatch = line.match(processPattern(bundleId)); - - return { - source: 'logs', - summary: line.trim(), - signal: getSignal(line), - exceptionType: fatalExceptionMatch?.[1]?.trim(), - processName: processMatch - ? bundleId - : line.includes(bundleId) - ? bundleId - : undefined, - pid: pid ?? (processMatch ? Number(processMatch[1]) : undefined), - rawLines: [line], - }; -}; - -type TimedLogLine = { - line: string; - occurredAt: number; -}; - -type AndroidCrashArtifact = AppCrashDetails & { - occurredAt: number; - triggerLine: string; - triggerOccurredAt?: number; -}; - -const CRASH_BLOCK_HEADER = '--------- beginning of crash'; - -const getLatestCrashBlock = (recentLogLines: TimedLogLine[]) => { - const lines = recentLogLines.map(({ line }) => line); - let latestCrashHeaderIndex = -1; - - for (let index = lines.length - 1; index >= 0; index -= 1) { - if (/FATAL EXCEPTION:|Process:\s+.+,\s+PID:/i.test(lines[index])) { - latestCrashHeaderIndex = index; - break; - } - } - - const blockStartIndex = Math.max( - lines.lastIndexOf(CRASH_BLOCK_HEADER), - latestCrashHeaderIndex - ); - - if (blockStartIndex === -1) { - return lines; - } - - return lines.slice(blockStartIndex); -}; - -const getCrashBlockForArtifact = ({ - artifact, - recentLogLines, -}: { - artifact: AndroidCrashArtifact; - recentLogLines: TimedLogLine[]; -}): string[] => { - const targetIndex = recentLogLines.findIndex( - ({ line, occurredAt }) => - line === artifact.triggerLine && - (artifact.triggerOccurredAt === undefined || - occurredAt === artifact.triggerOccurredAt) - ); - - if (targetIndex === -1) { - return artifact.rawLines ?? []; - } - - let blockStartIndex = targetIndex; - - for (let index = targetIndex; index >= 0; index -= 1) { - const { line } = recentLogLines[index]; - - if (line === CRASH_BLOCK_HEADER) { - blockStartIndex = index; - break; - } - } - - let blockEndIndex = recentLogLines.length; - - for (let index = targetIndex + 1; index < recentLogLines.length; index += 1) { - if (recentLogLines[index].line === CRASH_BLOCK_HEADER) { - blockEndIndex = index; - break; - } - } - - return recentLogLines - .slice(blockStartIndex, blockEndIndex) - .map(({ line }) => line); -}; - -const hydrateCrashArtifact = ({ - artifact, - recentLogLines, -}: { - artifact: AndroidCrashArtifact; - recentLogLines: TimedLogLine[]; -}): AppCrashDetails => { - const rawLines = getCrashBlockForArtifact({ artifact, recentLogLines }); - - if (rawLines.length === 0) { - return artifact; - } - - const parsedDetails = androidCrashParser.parse({ - contents: rawLines.join('\n'), - bundleId: artifact.processName ?? '', - pid: artifact.pid, - }); - - return { - ...artifact, - ...parsedDetails, - artifactType: artifact.artifactType, - artifactPath: artifact.artifactPath, - rawLines, - }; -}; - -const createCrashArtifact = ({ - details, - recentLogLines, -}: { - details: AppCrashDetails; - recentLogLines: TimedLogLine[]; -}): AndroidCrashArtifact => { - const occurredAt = Date.now(); - const rawLines = getLatestCrashBlock(recentLogLines); - const triggerOccurredAt = [...recentLogLines] - .reverse() - .find(({ line }) => line === details.summary)?.occurredAt; - const contents = - rawLines.length > 0 - ? rawLines.join('\n') - : (details.rawLines ?? []).join('\n'); - const parsedDetails = - details.processName !== undefined - ? androidCrashParser.parse({ - contents, - bundleId: details.processName, - pid: details.pid, - }) - : details; - - return { - ...parsedDetails, - occurredAt, - triggerLine: details.summary ?? '', - triggerOccurredAt, - artifactType: 'logcat', - rawLines: - rawLines.length > 0 - ? rawLines - : parsedDetails.rawLines ?? details.rawLines, - }; -}; - -const persistCrashArtifact = ({ - details, - crashArtifactWriter, -}: { - details: AppCrashDetails; - crashArtifactWriter?: CrashArtifactWriter; -}): AppCrashDetails => { - if (!crashArtifactWriter || details.artifactType !== 'logcat') { - return details; - } - - const artifactBody = details.rawLines?.join('\n'); - - if (!artifactBody) { - return details; - } - - return { - ...details, - artifactPath: crashArtifactWriter.persistArtifact({ - artifactKind: details.artifactType, - source: { - kind: 'text', - fileName: 'logcat.txt', - text: `${artifactBody}\n`, - }, - }), - }; -}; - -const getLatestCrashArtifact = ({ - crashArtifacts, - recentLogLines, - processName, - pid, - occurredAt, -}: CrashDetailsLookupOptions & { - crashArtifacts: AndroidCrashArtifact[]; - recentLogLines: TimedLogLine[]; -}): AppCrashDetails | null => { - const matchingByPid = pid - ? crashArtifacts.filter((artifact) => artifact.pid === pid) - : []; - const matchingByProcess = processName - ? crashArtifacts.filter((artifact) => artifact.processName === processName) - : []; - const candidates = - matchingByPid.length > 0 - ? matchingByPid - : matchingByProcess.length > 0 - ? matchingByProcess - : crashArtifacts; - const sortedCandidates = [...candidates].sort( - (left, right) => - Math.abs(left.occurredAt - occurredAt) - - Math.abs(right.occurredAt - occurredAt) - ); - - const artifact = sortedCandidates[0]; - - if (!artifact) { - return null; - } - - return hydrateCrashArtifact({ - artifact, - recentLogLines, - }); -}; - -const createAndroidLogEvent = ( - line: string, - bundleId: string -): AppMonitorEvent | null => { - const startMatch = line.match(startProcPattern(bundleId)); - - if (startMatch) { - return { - type: 'app_started', - pid: Number(startMatch[1]), - source: 'logs', - line, - }; - } - - const processMatch = line.match(processPattern(bundleId)); - - if (processMatch) { - return { - type: 'possible_crash', - pid: Number(processMatch[1]), - source: 'logs', - line, - crashDetails: getAndroidLogLineCrashDetails({ - line, - bundleId, - pid: Number(processMatch[1]), - }), - }; - } - - if (nativeCrashPattern(bundleId).test(line)) { - return { - type: 'possible_crash', - source: 'logs', - line, - crashDetails: getAndroidLogLineCrashDetails({ - line, - bundleId, - }), - }; - } - - const diedMatch = line.match(processDiedPattern(bundleId)); - - if (diedMatch) { - return { - type: 'app_exited', - pid: Number(diedMatch[1]), - source: 'logs', - line, - crashDetails: getAndroidLogLineCrashDetails({ - line, - bundleId, - pid: Number(diedMatch[1]), - }), - }; - } - - if ( - line.includes(bundleId) && - /fatal|crash|signal 11|signal 6|backtrace/i.test(line) - ) { - return { - type: 'possible_crash', - source: 'logs', - line, - crashDetails: getAndroidLogLineCrashDetails({ - line, - bundleId, - }), - }; - } - - return null; -}; - -export const createAndroidAppMonitor = ({ - adbId, - bundleId, - appUid, - crashArtifactWriter, -}: { - adbId: string; - bundleId: string; - appUid: number; - crashArtifactWriter?: CrashArtifactWriter; -}): AndroidAppMonitor => { - const emitter = getEmitter(); - - let isStarted = false; - let logcatProcess: Subprocess | null = null; - let logTask: Promise | null = null; - let recentLogLines: TimedLogLine[] = []; - let recentCrashArtifacts: AndroidCrashArtifact[] = []; - - const emit = (event: AppMonitorEvent) => { - emitter.emit(event); - }; - - const recordLogLine = (line: string) => { - recentLogLines = [ - ...recentLogLines, - { line, occurredAt: Date.now() }, - ].slice(-MAX_RECENT_LOG_LINES); - }; - - const recordCrashArtifact = (details?: AppCrashDetails) => { - if (!details) { - return; - } - - recentCrashArtifacts = [ - ...recentCrashArtifacts, - createCrashArtifact({ - details, - recentLogLines, - }), - ].slice(-MAX_RECENT_CRASH_ARTIFACTS); - }; - - const stopProcess = async (child: Subprocess | null) => { - if (!child) { - return; - } - - try { - (await child.nodeChildProcess).kill(); - } catch { - // Ignore termination failures for background monitors. - } - }; - - const startLogcat = async () => { - const logcatTimestamp = await adb.getLogcatTimestamp(adbId); - - logcatProcess = adb.startLogcat( - adbId, - getLogcatArgs(appUid, logcatTimestamp) - ); - - const currentProcess = logcatProcess; - - if (!currentProcess) { - return; - } - - logTask = (async () => { - try { - for await (const line of currentProcess) { - recordLogLine(line); - emit({ type: 'log', source: 'logs', line }); - - const event = createAndroidLogEvent(line, bundleId); - - if (event) { - if ( - event.type === 'possible_crash' || - event.type === 'app_exited' - ) { - recordCrashArtifact(event.crashDetails); - } - emit(event); - } - } - } catch (error) { - if ( - !(error instanceof SubprocessError && error.signalName === 'SIGTERM') - ) { - androidAppMonitorLogger.debug( - 'Android logcat monitor stopped', - error - ); - } - } - })(); - }; - - const start = async () => { - if (isStarted) { - return; - } - - try { - await startLogcat(); - isStarted = true; - } catch (error) { - const currentProcess = logcatProcess; - const currentTask = logTask; - - logcatProcess = null; - logTask = null; - - await stopProcess(currentProcess); - await currentTask; - - throw error; - } - }; - - const stop = async () => { - if (!isStarted) { - return; - } - - isStarted = false; - - const currentProcess = logcatProcess; - const currentTask = logTask; - - logcatProcess = null; - logTask = null; - - await stopProcess(currentProcess); - await currentTask; - }; - - const dispose = async () => { - await stop(); - emitter.clearAllListeners(); - recentLogLines = []; - recentCrashArtifacts = []; - }; - - const addListener = (listener: AppMonitorListener) => { - emitter.addListener(listener); - }; - - const removeListener = (listener: AppMonitorListener) => { - emitter.removeListener(listener); - }; - - return { - start, - stop, - dispose, - addListener, - removeListener, - getCrashDetails: async (options: CrashDetailsLookupOptions) => { - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) - ); - - const details = getLatestCrashArtifact({ - crashArtifacts: recentCrashArtifacts, - recentLogLines, - ...options, - }); - - if (!details) { - return null; - } - - return persistCrashArtifact({ - details, - crashArtifactWriter, - }); - }, - } satisfies AndroidAppMonitor; -}; - -export { createAndroidLogEvent }; -export type AndroidAppMonitor = AppMonitor & { - getCrashDetails: ( - options: CrashDetailsLookupOptions - ) => Promise; -}; diff --git a/packages/platform-ios/src/__tests__/app-monitor.test.ts b/packages/platform-ios/src/__tests__/app-monitor.test.ts deleted file mode 100644 index fe50fafa..00000000 --- a/packages/platform-ios/src/__tests__/app-monitor.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { - createIosDeviceAppMonitor, - createIosSimulatorAppMonitor, - createUnifiedLogEvent, -} from '../app-monitor.js'; -import * as simctl from '../xcrun/simctl.js'; -import * as devicectl from '../xcrun/devicectl.js'; -import * as diagnostics from '../crash-diagnostics.js'; -import { createCrashArtifactWriter } from '@react-native-harness/tools'; -import type { Subprocess } from '@react-native-harness/tools'; - -const createStreamingSubprocess = ( - chunks: Array<{ line: string; delayMs?: number }> -): Subprocess => - ({ - nodeChildProcess: Promise.resolve({ - kill: vi.fn(), - }), - [Symbol.asyncIterator]: async function* () { - for (const { line, delayMs = 0 } of chunks) { - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - - yield line; - } - }, - } as unknown as Subprocess); - -const artifactRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-ios-monitor-artifacts-') -); - -describe('createUnifiedLogEvent', () => { - it('extracts crash details from simulator log lines', () => { - const event = createUnifiedLogEvent({ - line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', - processNames: ['HarnessPlayground', 'com.harnessplayground'], - }); - - expect(event).toMatchObject({ - type: 'possible_crash', - source: 'logs', - isConfirmed: true, - crashDetails: { - source: 'logs', - processName: 'HarnessPlayground', - pid: 1234, - exceptionType: 'NSInternalInconsistencyException', - }, - }); - }); - - it('detects Swift fatal errors from simulator logs', () => { - const event = createUnifiedLogEvent({ - line: '2026-03-13 10:29:13.868 Df HarnessPlayground[34784:8f92b3] (libswiftCore.dylib) HarnessPlayground/AppDelegate.swift:31: Fatal error: Intentional pre-RN startup crash', - processNames: ['HarnessPlayground', 'com.harnessplayground'], - }); - - expect(event).toMatchObject({ - type: 'possible_crash', - source: 'logs', - isConfirmed: true, - crashDetails: { - source: 'logs', - processName: 'HarnessPlayground', - pid: 34784, - }, - }); - }); - - it('ignores unrelated lines that only mention the bundle identifier', () => { - const event = createUnifiedLogEvent({ - line: '2026-03-12 11:35:08.000 runningboardd[55:aaaa] Acquiring assertion for com.harnessplayground', - processNames: ['HarnessPlayground', 'com.harnessplayground'], - }); - - expect(event).toBeNull(); - }); -}); - -afterEach(() => { - fs.rmSync(artifactRoot, { recursive: true, force: true }); - fs.mkdirSync(artifactRoot, { recursive: true }); -}); - -describe('createIosSimulatorAppMonitor', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('starts simctl log stream', async () => { - const streamLogsSpy = vi - .spyOn(simctl, 'streamLogs') - .mockReturnValue(createStreamingSubprocess([])); - - vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ - Bundle: 'com.harnessplayground', - CFBundleIdentifier: 'com.harnessplayground', - CFBundleExecutable: 'HarnessPlayground', - CFBundleName: 'HarnessPlayground', - CFBundleDisplayName: 'Harness Playground', - Path: '/tmp/HarnessPlayground.app', - }); - - const monitor = createIosSimulatorAppMonitor({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - }); - - await monitor.start(); - await monitor.stop(); - - expect(streamLogsSpy).toHaveBeenCalledWith( - 'sim-udid', - 'process == "HarnessPlayground" OR process == "com.harnessplayground"' - ); - }); - - it('returns best-effort simulator crash details from recent log blocks', async () => { - vi.spyOn(simctl, 'streamLogs').mockReturnValue( - createStreamingSubprocess([ - { - line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', - }, - { - line: '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] *** First throw call stack:', - delayMs: 10, - }, - ]) - ); - vi.spyOn(diagnostics, 'waitForCrashArtifact').mockResolvedValue({ - source: 'logs', - processName: 'HarnessPlayground', - pid: 1234, - exceptionType: 'NSInternalInconsistencyException', - summary: - '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', - rawLines: [ - '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', - ], - }); - vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ - Bundle: 'com.harnessplayground', - CFBundleIdentifier: 'com.harnessplayground', - CFBundleExecutable: 'HarnessPlayground', - CFBundleName: 'HarnessPlayground', - CFBundleDisplayName: 'Harness Playground', - Path: '/tmp/HarnessPlayground.app', - }); - - const monitor = createIosSimulatorAppMonitor({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - }); - - await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 25)); - - const details = await monitor.getCrashDetails({ - pid: 1234, - occurredAt: Date.now(), - }); - - await monitor.stop(); - - expect(details).toMatchObject({ - processName: 'HarnessPlayground', - pid: 1234, - exceptionType: 'NSInternalInconsistencyException', - }); - }); - - it('prefers a matched simulator crash report when one is found', async () => { - vi.spyOn(simctl, 'streamLogs').mockReturnValue( - createStreamingSubprocess([ - { - line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', - }, - ]) - ); - const sourcePath = join( - artifactRoot, - 'HarnessPlayground-2026-03-12-122756.ips' - ); - fs.writeFileSync(sourcePath, 'simulator crash report', 'utf8'); - vi.spyOn(diagnostics, 'waitForCrashArtifact').mockResolvedValue({ - artifactType: 'ios-crash-report', - artifactPath: sourcePath, - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGTRAP', - exceptionType: 'EXC_BREAKPOINT', - summary: 'simulator crash report', - rawLines: ['simulator crash report'], - }); - vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ - Bundle: 'com.harnessplayground', - CFBundleIdentifier: 'com.harnessplayground', - CFBundleExecutable: 'HarnessPlayground', - CFBundleName: 'HarnessPlayground', - CFBundleDisplayName: 'Harness Playground', - Path: '/tmp/HarnessPlayground.app', - }); - - const monitor = createIosSimulatorAppMonitor({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - crashArtifactWriter: createCrashArtifactWriter({ - runnerName: 'ios-simulator', - platformId: 'ios', - rootDir: join(artifactRoot, '.harness', 'crash-reports'), - runTimestamp: '2026-03-12T11-35-08-000Z', - }), - }); - - await monitor.start(); - const details = await monitor.getCrashDetails({ - pid: 1234, - occurredAt: Date.now(), - }); - await monitor.stop(); - - expect(details).toMatchObject({ - artifactType: 'ios-crash-report', - summary: 'simulator crash report', - }); - }); -}); - -describe('createIosDeviceAppMonitor', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('polls device processes and emits app_exited when the app disappears', async () => { - vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ - bundleIdentifier: 'com.harnessplayground', - name: 'HarnessPlayground', - version: '1.0', - url: '/private/var/HarnessPlayground.app', - }); - vi.spyOn(diagnostics, 'collectCrashArtifacts').mockResolvedValue([]); - const getProcesses = vi - .spyOn(devicectl, 'getProcesses') - .mockResolvedValueOnce([ - { - executable: '/private/var/HarnessPlayground.app/HarnessPlayground', - processIdentifier: 4321, - }, - ]) - .mockResolvedValueOnce([]) - .mockResolvedValue([]); - - const events: Array<{ type: string }> = []; - const monitor = createIosDeviceAppMonitor({ - deviceId: 'device-udid', - bundleId: 'com.harnessplayground', - }); - monitor.addListener((event) => { - events.push(event); - }); - - await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 1200)); - await monitor.stop(); - - expect(getProcesses).toHaveBeenCalled(); - expect(events.some((event) => event.type === 'app_exited')).toBe(true); - }); - - it('enriches device crashes with Apple-native pulled crash reports', async () => { - vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ - bundleIdentifier: 'com.harnessplayground', - name: 'HarnessPlayground', - version: '1.0', - url: '/private/var/HarnessPlayground.app', - }); - vi.spyOn(devicectl, 'getProcesses').mockResolvedValue([]); - vi.spyOn(diagnostics, 'collectCrashArtifacts').mockResolvedValue([]); - - const sourcePath = join(artifactRoot, 'HarnessPlayground.crash'); - fs.writeFileSync(sourcePath, 'full crash report', 'utf8'); - vi.spyOn(diagnostics, 'waitForCrashArtifact').mockResolvedValue({ - artifactType: 'ios-crash-report', - artifactPath: sourcePath, - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGABRT', - exceptionType: 'NSInternalInconsistencyException', - summary: 'full crash report', - rawLines: ['full crash report'], - }); - - const monitor = createIosDeviceAppMonitor({ - deviceId: 'device-udid', - bundleId: 'com.harnessplayground', - crashArtifactWriter: createCrashArtifactWriter({ - runnerName: 'ios-device', - platformId: 'ios', - rootDir: join(artifactRoot, '.harness', 'crash-reports'), - runTimestamp: '2026-03-12T11-35-08-000Z', - }), - }); - - await monitor.start(); - const details = await monitor.getCrashDetails({ - pid: 1234, - occurredAt: Date.now(), - }); - await monitor.stop(); - - expect(details).toMatchObject({ - artifactType: 'ios-crash-report', - summary: 'full crash report', - }); - }); -}); diff --git a/packages/platform-ios/src/app-monitor.ts b/packages/platform-ios/src/app-monitor.ts deleted file mode 100644 index 0123669d..00000000 --- a/packages/platform-ios/src/app-monitor.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { - type AppMonitor, - type AppCrashDetails, - type AppMonitorEvent, - type AppMonitorListener, - type CrashArtifactWriter, - type CrashDetailsLookupOptions, -} from '@react-native-harness/platforms'; -import { - escapeRegExp, - getEmitter, - logger, - type Subprocess, -} from '@react-native-harness/tools'; -import * as devicectl from './xcrun/devicectl.js'; -import * as simctl from './xcrun/simctl.js'; -import { - collectCrashArtifacts, - waitForCrashArtifact, -} from './crash-diagnostics.js'; - -const iosAppMonitorLogger = logger.child('ios-app-monitor'); - -const MAX_RECENT_LOG_LINES = 200; -const MAX_RECENT_CRASH_ARTIFACTS = 10; -const CRASH_ARTIFACT_SETTLE_DELAY_MS = 300; -const APP_EXIT_POLL_INTERVAL_MS = 1000; - -type TimedLogLine = { - line: string; - occurredAt: number; -}; - -type IosCrashArtifact = AppCrashDetails & { - occurredAt: number; -}; - -const getSignal = (line: string) => { - const namedSignalMatch = line.match(/\b(SIG[A-Z0-9]+)\b/); - - if (namedSignalMatch) { - return namedSignalMatch[1]; - } - - const signalNumberMatch = line.match(/signal\s+(\d+)/i); - - if (signalNumberMatch) { - return `signal ${signalNumberMatch[1]}`; - } - - const exceptionTypeMatch = line.match(/\b(EXC_[A-Z_]+)\b/); - - if (exceptionTypeMatch) { - return exceptionTypeMatch[1]; - } - - return undefined; -}; - -const getProcessName = (line: string, processNames: string[]) => - processNames.find((processName) => - new RegExp(`\\b${escapeRegExp(processName)}\\b`).test(line) - ); - -const getPid = (line: string, processNames: string[]) => { - for (const processName of processNames) { - const match = line.match( - new RegExp( - `\\b${escapeRegExp( - processName - )}(?:\\([^)]*\\))?\\[(\\d+)(?::[^\\]]+)?\\]` - ) - ); - - if (match) { - return Number(match[1]); - } - } - - const genericMatch = line.match(/\[(\d+)\]/); - - if (genericMatch) { - return Number(genericMatch[1]); - } - - return undefined; -}; - -const isRelevantProcessLine = (line: string, processNames: string[]) => - processNames.some((processName) => - new RegExp(`\\b${escapeRegExp(processName)}(?:\\[|\\b)`).test(line) - ); - -const isRelevantProcessLogLine = (line: string, processNames: string[]) => - processNames.some((processName) => - new RegExp(`\\b${escapeRegExp(processName)}(?:\\([^)]*\\))?\\[`).test(line) - ); - -const isCrashSignal = (line: string) => - /uncaught exception|terminating app due to|fatal error|EXC_[A-Z_]+|termination reason/i.test( - line - ) || /\bSIG[A-Z]{2,}\b/.test(line); - -const getIosLogCrashDetails = ({ - line, - processNames, -}: { - line: string; - processNames: string[]; -}): AppCrashDetails => { - const exceptionMatch = line.match(/exception[^:]*:\s*([^,]+)/i); - - return { - source: 'logs', - summary: line.trim(), - signal: getSignal(line), - exceptionType: exceptionMatch?.[1]?.trim(), - processName: getProcessName(line, processNames), - pid: getPid(line, processNames), - rawLines: [line], - }; -}; - -export const createUnifiedLogEvent = ({ - line, - processNames, -}: { - line: string; - processNames: string[]; -}): AppMonitorEvent | null => { - if (!isRelevantProcessLine(line, processNames)) { - return null; - } - - if (isCrashSignal(line)) { - return { - type: 'possible_crash', - source: 'logs', - line, - isConfirmed: true, - crashDetails: getIosLogCrashDetails({ - line, - processNames, - }), - }; - } - - return null; -}; - -const createAppMonitorBase = () => { - const emitter = getEmitter(); - let isStarted = false; - let recentLogLines: TimedLogLine[] = []; - let recentCrashArtifacts: IosCrashArtifact[] = []; - - const emit = (event: AppMonitorEvent) => { - emitter.emit(event); - }; - - const recordLogLine = (line: string) => { - recentLogLines = [ - ...recentLogLines, - { line, occurredAt: Date.now() }, - ].slice(-MAX_RECENT_LOG_LINES); - }; - - const recordCrashArtifact = (details: AppCrashDetails) => { - recentCrashArtifacts = [ - ...recentCrashArtifacts, - { - ...details, - occurredAt: Date.now(), - }, - ].slice(-MAX_RECENT_CRASH_ARTIFACTS); - }; - - const getLatestCrashArtifact = ( - options: CrashDetailsLookupOptions - ): AppCrashDetails | null => { - const matchingByPid = options.pid - ? recentCrashArtifacts.filter((artifact) => artifact.pid === options.pid) - : []; - const matchingByProcess = options.processName - ? recentCrashArtifacts.filter( - (artifact) => artifact.processName === options.processName - ) - : []; - const candidates = - matchingByPid.length > 0 - ? matchingByPid - : matchingByProcess.length > 0 - ? matchingByProcess - : recentCrashArtifacts; - const preferredCandidates = candidates.filter( - (artifact) => artifact.artifactType === 'ios-crash-report' - ); - const prioritizedCandidates = - preferredCandidates.length > 0 ? preferredCandidates : candidates; - - return ( - [...prioritizedCandidates].sort( - (left, right) => - Math.abs(left.occurredAt - options.occurredAt) - - Math.abs(right.occurredAt - options.occurredAt) - )[0] ?? null - ); - }; - - const handleLogEvent = (line: string, processNames: string[]) => { - if (!isRelevantProcessLogLine(line, processNames)) { - return; - } - - recordLogLine(line); - emit({ type: 'log', source: 'logs', line }); - - const event = createUnifiedLogEvent({ - line, - processNames, - }); - - if (!event) { - return; - } - - if ( - (event.type === 'possible_crash' || event.type === 'app_exited') && - event.crashDetails - ) { - recordCrashArtifact(event.crashDetails); - } - - emit(event); - }; - - const stopProcess = async (child: Subprocess | null) => { - if (!child) { - return; - } - - try { - (await child.nodeChildProcess).kill(); - } catch { - // Ignore termination failures for background monitors. - } - }; - - const createLifecycle = ({ - startLogMonitor, - stopLogMonitor, - getCrashDetails, - }: { - startLogMonitor: (startedAt: number) => Promise; - stopLogMonitor: () => Promise; - getCrashDetails: ( - options: CrashDetailsLookupOptions - ) => Promise; - }): IosAppMonitor => { - const start = async () => { - if (isStarted) { - return; - } - - const startedAt = Date.now(); - - try { - await startLogMonitor(startedAt); - isStarted = true; - } catch (error) { - await stopLogMonitor(); - throw error; - } - }; - - const stop = async () => { - if (!isStarted) { - return; - } - - isStarted = false; - await stopLogMonitor(); - }; - - const dispose = async () => { - await stop(); - emitter.clearAllListeners(); - recentLogLines = []; - recentCrashArtifacts = []; - }; - - const addListener = (listener: AppMonitorListener) => { - emitter.addListener(listener); - }; - - const removeListener = (listener: AppMonitorListener) => { - emitter.removeListener(listener); - }; - - return { - start, - stop, - dispose, - addListener, - removeListener, - getCrashDetails, - }; - }; - - return { - createLifecycle, - emit, - handleLogEvent, - recordCrashArtifact, - getLatestCrashArtifact, - getRecentLogLines: () => recentLogLines, - stopProcess, - }; -}; - -const getRecentLogBlock = ({ - recentLogLines, - occurredAt, -}: { - recentLogLines: TimedLogLine[]; - occurredAt: number; -}) => { - const nearbyLines = recentLogLines.filter( - (line) => Math.abs(line.occurredAt - occurredAt) <= 1000 - ); - - return nearbyLines.map((line) => line.line); -}; - -const toLogOnlyDetails = ({ - artifact, - recentLogLines, - occurredAt, -}: { - artifact: AppCrashDetails; - recentLogLines: TimedLogLine[]; - occurredAt: number; -}): AppCrashDetails => { - const relatedLogLines = getRecentLogBlock({ - recentLogLines, - occurredAt, - }); - - return { - ...artifact, - summary: - relatedLogLines.length > 0 - ? relatedLogLines.join('\n') - : artifact.summary, - rawLines: relatedLogLines.length > 0 ? relatedLogLines : artifact.rawLines, - artifactType: undefined, - artifactPath: undefined, - }; -}; - -const createCrashDetailsLookup = ({ - targetId, - targetType, - bundleId, - processNames, - monitorStartedAt, - crashArtifactWriter, - base, -}: { - targetId: string; - targetType: 'simulator' | 'device'; - bundleId: string; - processNames: string[]; - monitorStartedAt: number; - crashArtifactWriter?: CrashArtifactWriter; - base: ReturnType; -}) => { - return async (options: CrashDetailsLookupOptions) => { - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) - ); - - const artifact = await waitForCrashArtifact({ - lookup: options, - options: { - targetId, - targetType, - bundleId, - processNames, - crashArtifactWriter, - minOccurredAt: monitorStartedAt, - }, - getFallbackArtifact: () => base.getLatestCrashArtifact(options), - recordArtifact: (details) => base.recordCrashArtifact(details), - }); - - if (!artifact) { - return null; - } - - if (artifact.artifactType === 'ios-crash-report') { - return artifact; - } - - return toLogOnlyDetails({ - artifact, - recentLogLines: base.getRecentLogLines(), - occurredAt: options.occurredAt, - }); - }; -}; - -export const createIosSimulatorAppMonitor = ({ - udid, - bundleId, - crashArtifactWriter, -}: { - udid: string; - bundleId: string; - crashArtifactWriter?: CrashArtifactWriter; -}): IosAppMonitor => { - const base = createAppMonitorBase(); - let logProcess: Subprocess | null = null; - let logTask: Promise | null = null; - let processNames = [bundleId]; - let monitorStartedAt = 0; - - const startLogMonitor = async (startedAt: number) => { - monitorStartedAt = startedAt; - const appInfo = await simctl.getAppInfo(udid, bundleId); - processNames = [ - ...new Set( - [appInfo?.CFBundleExecutable, appInfo?.CFBundleName, bundleId].filter( - (value): value is string => Boolean(value) - ) - ), - ]; - - const predicate = processNames - .map((name) => `process == "${name}"`) - .join(' OR '); - - logProcess = simctl.streamLogs(udid, predicate); - - const currentProcess = logProcess; - - if (!currentProcess) { - return; - } - - logTask = (async () => { - try { - for await (const line of currentProcess) { - base.handleLogEvent(line, processNames); - } - } catch (error) { - iosAppMonitorLogger.debug('iOS simulator log monitor stopped', error); - } - })(); - }; - - const stopLogMonitor = async () => { - const currentProcess = logProcess; - const currentTask = logTask; - - logProcess = null; - logTask = null; - - await base.stopProcess(currentProcess); - await currentTask; - }; - - return base.createLifecycle({ - startLogMonitor, - stopLogMonitor, - getCrashDetails: (options) => - createCrashDetailsLookup({ - targetId: udid, - targetType: 'simulator', - bundleId, - processNames, - monitorStartedAt, - crashArtifactWriter, - base, - })(options), - }); -}; - -export const createIosDeviceAppMonitor = ({ - deviceId, - bundleId, - crashArtifactWriter, -}: { - deviceId: string; - bundleId: string; - crashArtifactWriter?: CrashArtifactWriter; -}): IosAppMonitor => { - const base = createAppMonitorBase(); - let pollTask: Promise | null = null; - let stopPolling = false; - let monitorStartedAt = 0; - let processNames = [bundleId]; - let lastKnownPid: number | undefined; - - const startLogMonitor = async (startedAt: number) => { - monitorStartedAt = startedAt; - const appInfo = await devicectl.getAppInfo(deviceId, bundleId); - processNames = [ - ...new Set( - [appInfo?.name, bundleId].filter((value): value is string => - Boolean(value) - ) - ), - ]; - - stopPolling = false; - pollTask = (async () => { - let wasRunning = false; - - while (!stopPolling) { - try { - const processes = await devicectl.getProcesses(deviceId); - const matchingProcess = processes.find((process) => { - if (appInfo?.url) { - return process.executable.startsWith(appInfo.url); - } - - return processNames.some((processName) => - process.executable.includes(processName) - ); - }); - - if (matchingProcess) { - wasRunning = true; - lastKnownPid = matchingProcess.processIdentifier; - } else if (wasRunning) { - const crashDetails: AppCrashDetails = { - source: 'polling', - processName: processNames[0], - pid: lastKnownPid, - summary: `${processNames[0] ?? bundleId} exited on device`, - }; - - base.recordCrashArtifact(crashDetails); - base.emit({ - type: 'app_exited', - source: 'polling', - pid: lastKnownPid, - isConfirmed: true, - crashDetails, - }); - wasRunning = false; - } - } catch (error) { - iosAppMonitorLogger.debug('iOS device process polling failed', error); - } - - await new Promise((resolve) => - setTimeout(resolve, APP_EXIT_POLL_INTERVAL_MS) - ); - } - })(); - - const initialArtifacts = await collectCrashArtifacts({ - targetId: deviceId, - targetType: 'device', - bundleId, - processNames, - crashArtifactWriter, - minOccurredAt: monitorStartedAt, - }); - - for (const artifact of initialArtifacts) { - base.recordCrashArtifact(artifact); - } - }; - - const stopLogMonitor = async () => { - stopPolling = true; - await pollTask; - pollTask = null; - }; - - return base.createLifecycle({ - startLogMonitor, - stopLogMonitor, - getCrashDetails: (options) => - createCrashDetailsLookup({ - targetId: deviceId, - targetType: 'device', - bundleId, - processNames, - monitorStartedAt, - crashArtifactWriter, - base, - })(options), - }); -}; - -export type IosAppMonitor = AppMonitor & { - getCrashDetails: ( - options: CrashDetailsLookupOptions - ) => Promise; -}; diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 1182f3aa..f6eef7e1 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -10,16 +10,12 @@ export type { AppSessionListener, AppSessionLog, AppSessionState, - AppMonitor, - AppMonitorEvent, - AppMonitorListener, AppLaunchOptions, CrashArtifactKind, CrashDetailsLookupOptions, CrashEnrichmentArtifact, CrashArtifactSource, CrashArtifactWriter, - CreateAppMonitorOptions, HarnessPlatform, HarnessPlatformInitOptions, CollectNativeCoverageOptions, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 00fde7c7..2c370238 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -47,10 +47,6 @@ export type CrashArtifactWriter = { }) => string; }; -export type CreateAppMonitorOptions = { - crashArtifactWriter?: CrashArtifactWriter; -}; - export type CrashDetailsLookupOptions = { processName?: string; pid?: number; @@ -58,45 +54,6 @@ export type CrashDetailsLookupOptions = { testFilePath?: string; }; -export type AppMonitorEvent = - | { - type: 'app_started'; - pid?: number; - source?: 'polling' | 'logs'; - line?: string; - } - | { - type: 'app_exited'; - pid?: number; - source?: 'polling' | 'logs'; - line?: string; - isConfirmed?: boolean; - crashDetails?: AppCrashDetails; - } - | { - type: 'possible_crash'; - pid?: number; - source?: 'polling' | 'logs'; - line?: string; - isConfirmed?: boolean; - crashDetails?: AppCrashDetails; - } - | { - type: 'log'; - source?: 'polling' | 'logs'; - line: string; - }; - -export type AppMonitorListener = (event: AppMonitorEvent) => void; - -export type AppMonitor = { - start: () => Promise; - stop: () => Promise; - dispose: () => Promise; - addListener: (listener: AppMonitorListener) => void; - removeListener: (listener: AppMonitorListener) => void; -}; - export type AppSessionLog = { line: string; occurredAt: number; From c5b6bee1bb2488e558194c3d060eadeef8e33344 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 10:31:09 +0200 Subject: [PATCH 14/17] fix: use correct filters for dropbox --- .../src/__tests__/crash-diagnostics.test.ts | 33 +++++++++++++++++++ packages/platform-android/src/adb.ts | 28 ++++++++++------ .../platform-android/src/crash-diagnostics.ts | 19 ++--------- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/platform-android/src/__tests__/crash-diagnostics.test.ts b/packages/platform-android/src/__tests__/crash-diagnostics.test.ts index 6f09f7aa..001c5e4d 100644 --- a/packages/platform-android/src/__tests__/crash-diagnostics.test.ts +++ b/packages/platform-android/src/__tests__/crash-diagnostics.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + collectDropboxArtifacts, filterExitInfo, getBestDropboxArtifact, parseDropboxOutput, @@ -28,6 +29,38 @@ backtrace: #00 pc 00001234 /data/app/lib/libapp.so `; +const deviceJavaCrashDropbox = ` +Drop box contents: 8 entries +Searching for: data_app_crash + +======================================== +2026-05-29 10:22:34 data_app_crash (text, 1328 bytes) +SystemUptimeMs: 79467172 +Process: com.harnessplayground +PID: 586 +Package: com.harnessplayground v1 (1.0) +java.lang.RuntimeException: Intentional asynchronous Kotlin crash +\tat com.harnessplayground.PlaygroundCrashModule.crashFromKotlinAsync(PlaygroundCrashModule.kt:44) +`; + +describe('collectDropboxArtifacts', () => { + it('matches harness playground crashes from merged per-tag dropbox output', async () => { + const artifacts = await collectDropboxArtifacts({ + bundleId: 'com.harnessplayground', + getDropboxOutput: async () => deviceJavaCrashDropbox, + occurredAt: Date.now(), + pid: 586, + }); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]).toMatchObject({ + artifactType: 'dropbox-crash', + pid: 586, + processName: 'com.harnessplayground', + }); + }); +}); + describe('parseDropboxOutput', () => { it('parses java and native dropbox entries', () => { const entries = parseDropboxOutput(`${javaCrashDropbox}\n${nativeCrashDropbox}`); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 87e192af..7c433b38 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -800,17 +800,25 @@ export const getDropboxPrint = async ( adbId: string, tags: readonly string[] = DROPBOX_CRASH_TAGS, ): Promise => { - const { stdout } = await spawn(getAdbBinaryPath(), [ - '-s', - adbId, - 'shell', - 'dumpsys', - 'dropbox', - '--print', - ...tags, - ]); + // Android treats multiple args after --print as one search string, so each + // tag must be queried separately and merged on the host. + const outputs = await Promise.all( + tags.map(async (tag) => { + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'dumpsys', + 'dropbox', + '--print', + tag, + ]); + + return stdout; + }), + ); - return stdout; + return outputs.join('\n'); }; export const getActivityExitInfo = async ( diff --git a/packages/platform-android/src/crash-diagnostics.ts b/packages/platform-android/src/crash-diagnostics.ts index 77412f11..d3f12ac5 100644 --- a/packages/platform-android/src/crash-diagnostics.ts +++ b/packages/platform-android/src/crash-diagnostics.ts @@ -249,28 +249,15 @@ const getMatchingDropboxEntries = ({ bundleId, pid, occurredAt, - minOccurredAt, }: { output: string; bundleId: string; pid?: number; occurredAt: number; minOccurredAt?: number; -}) => { - const minTimestamp = - minOccurredAt !== undefined ? minOccurredAt - 60_000 : undefined; - - return parseDropboxOutput(output) +}) => + parseDropboxOutput(output) .filter((entry) => matchesDropboxEntry({ entry, bundleId, pid })) - .filter((entry) => { - if (minTimestamp === undefined) { - return true; - } - - const entryTimestamp = parseDropboxTimestamp(entry.timestamp); - - return entryTimestamp === 0 || entryTimestamp >= minTimestamp; - }) .map((entry) => toDropboxCrashArtifact({ entry, @@ -286,7 +273,6 @@ const getMatchingDropboxEntries = ({ return right.occurredAt - left.occurredAt; }); -}; const persistDropboxArtifact = ({ artifact, @@ -342,7 +328,6 @@ export const collectDropboxArtifacts = async ({ bundleId, pid: lookup.pid, occurredAt: lookup.occurredAt, - minOccurredAt, }).map((artifact) => persistDropboxArtifact({ artifact, From 19a911ba28f8f94c2406ab3bc7e6bf5396f7a76f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 10:48:40 +0200 Subject: [PATCH 15/17] fix: print crash artifact paths relative to cwd Add a shared tools formatPath helper and use it when rendering native crash artifact paths in Jest error output. --- actions/shared/index.cjs | 33 ++++++++++--------- .../jest/src/__tests__/crash-monitor.test.ts | 6 +++- packages/jest/src/__tests__/errors.test.ts | 30 ++++++++++++++--- packages/jest/src/errors.ts | 6 ++-- packages/tools/src/__tests__/path.test.ts | 26 +++++++++++++++ packages/tools/src/index.ts | 1 + packages/tools/src/path.ts | 11 +++++++ 7 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 packages/tools/src/__tests__/path.test.ts create mode 100644 packages/tools/src/path.ts diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index ff5bc14b..826790c6 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -633,8 +633,8 @@ function getErrorMap() { // ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js var makeIssue = (params) => { - const { data, path: path7, errorMaps, issueData } = params; - const fullPath = [...path7, ...issueData.path || []]; + const { data, path: path8, errorMaps, issueData } = params; + const fullPath = [...path8, ...issueData.path || []]; const fullIssue = { ...issueData, path: fullPath @@ -750,11 +750,11 @@ var errorUtil; // ../../node_modules/zod/dist/esm/v3/types.js var ParseInputLazyPath = class { - constructor(parent, value, path7, key) { + constructor(parent, value, path8, key) { this._cachedPath = []; this.parent = parent; this.data = value; - this._path = path7; + this._path = path8; this._key = key; } get path() { @@ -4345,14 +4345,17 @@ var HarnessError = class extends Error { var import_node_path3 = __toESM(require("path"), 1); var import_node_fs3 = __toESM(require("fs"), 1); +// ../tools/dist/path.js +var import_node_path4 = __toESM(require("path"), 1); + // ../tools/dist/crash-artifacts.js var import_node_fs4 = __toESM(require("fs"), 1); -var import_node_path4 = __toESM(require("path"), 1); -var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join(process.cwd(), ".harness", "crash-reports"); +var import_node_path5 = __toESM(require("path"), 1); +var DEFAULT_ARTIFACT_ROOT = import_node_path5.default.join(process.cwd(), ".harness", "crash-reports"); // ../tools/dist/harness-artifacts.js var import_node_fs5 = __toESM(require("fs"), 1); -var import_node_path5 = __toESM(require("path"), 1); +var import_node_path6 = __toESM(require("path"), 1); // ../plugins/dist/utils.js var isHookTree = (value) => { @@ -4512,13 +4515,13 @@ var ConfigLoadError = class extends HarnessError { }; // ../config/dist/reader.js -var import_node_path6 = __toESM(require("path"), 1); +var import_node_path7 = __toESM(require("path"), 1); var import_node_fs6 = __toESM(require("fs"), 1); var import_node_module2 = require("module"); var import_meta = {}; var extensions = [".js", ".mjs", ".cjs", ".json"]; var importUp = async (dir, name) => { - const filePath = import_node_path6.default.join(dir, name); + const filePath = import_node_path7.default.join(dir, name); for (const ext of extensions) { const filePathWithExt = `${filePath}${ext}`; if (import_node_fs6.default.existsSync(filePathWithExt)) { @@ -4539,8 +4542,8 @@ var importUp = async (dir, name) => { } catch (error) { if (error instanceof ZodError) { const validationErrors = error.errors.map((err) => { - const path7 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : ""; - return `${err.message}${path7}`; + const path8 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : ""; + return `${err.message}${path8}`; }); throw new ConfigValidationError(filePathWithExt, validationErrors); } @@ -4548,7 +4551,7 @@ var importUp = async (dir, name) => { } } } - const parentDir = import_node_path6.default.dirname(dir); + const parentDir = import_node_path7.default.dirname(dir); if (parentDir === dir) { throw new ConfigNotFoundError(dir); } @@ -4563,7 +4566,7 @@ var getConfig = async (dir) => { }; // src/shared/index.ts -var import_node_path7 = __toESM(require("path")); +var import_node_path8 = __toESM(require("path")); var import_node_fs7 = __toESM(require("fs")); var getHostAndroidSystemImageArch = () => { switch (process.arch) { @@ -4632,7 +4635,7 @@ var run = async () => { if (!runnerInput) { throw new Error("Runner input is required"); } - const projectRoot = projectRootInput ? import_node_path7.default.resolve(projectRootInput) : process.cwd(); + const projectRoot = projectRootInput ? import_node_path8.default.resolve(projectRootInput) : process.cwd(); console.info(`Loading React Native Harness config from: ${projectRoot}`); const { config, projectRoot: resolvedProjectRoot } = await getConfig( projectRoot @@ -4646,7 +4649,7 @@ var run = async () => { throw new Error("GITHUB_OUTPUT environment variable is not set"); } const resolvedRunner = getResolvedRunner(runner); - const relativeProjectRoot = import_node_path7.default.relative(process.cwd(), resolvedProjectRoot) || "."; + const relativeProjectRoot = import_node_path8.default.relative(process.cwd(), resolvedProjectRoot) || "."; const output = `config=${JSON.stringify( resolvedRunner )} diff --git a/packages/jest/src/__tests__/crash-monitor.test.ts b/packages/jest/src/__tests__/crash-monitor.test.ts index 36c3a350..95238776 100644 --- a/packages/jest/src/__tests__/crash-monitor.test.ts +++ b/packages/jest/src/__tests__/crash-monitor.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; import type { AppSession, AppSessionEvent, @@ -166,7 +167,10 @@ describe('createCrashMonitor', () => { '/tmp/.harness/crash-reports/crash-logcat.txt' ); expect(error.message).toContain( - 'Harness extracted the crash log: /tmp/.harness/crash-reports/crash-logcat.txt' + `Harness extracted the crash log: ${path.relative( + process.cwd(), + '/tmp/.harness/crash-reports/crash-logcat.txt' + )}` ); }); diff --git a/packages/jest/src/__tests__/errors.test.ts b/packages/jest/src/__tests__/errors.test.ts index cdb427e1..9aa7943e 100644 --- a/packages/jest/src/__tests__/errors.test.ts +++ b/packages/jest/src/__tests__/errors.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import path from 'node:path'; import { NativeCrashError, PlatformReadyTimeoutError } from '../errors.js'; describe('PlatformReadyTimeoutError', () => { @@ -13,29 +14,48 @@ describe('NativeCrashError', () => { it('reports the extracted crash log path when available', () => { const error = new NativeCrashError('/tmp/crash.harness.ts', { phase: 'execution', - artifactPath: '/tmp/.harness/crash-reports/crash.ips', + artifactPath: path.join( + process.cwd(), + '.harness', + 'crash-reports', + 'crash.ips' + ), }); expect(error.message).toContain( - 'Harness extracted the crash log: /tmp/.harness/crash-reports/crash.ips' + `Harness extracted the crash log: ${path.join( + '.harness', + 'crash-reports', + 'crash.ips' + )}` ); }); it('lists enrichment artifact paths when available', () => { const error = new NativeCrashError('/tmp/crash.harness.ts', { phase: 'execution', - artifactPath: '/tmp/.harness/crash-reports/logcat.txt', + artifactPath: path.join( + process.cwd(), + '.harness', + 'crash-reports', + 'logcat.txt' + ), enrichmentArtifacts: [ { artifactType: 'dropbox-native-crash', - artifactPath: '/tmp/.harness/crash-reports/dropbox-native-crash.txt', + artifactPath: path.join( + process.cwd(), + '.harness', + 'crash-reports', + 'dropbox-native-crash.txt' + ), }, ], }); expect(error.message).toContain('Additional crash artifacts:'); expect(error.message).toContain( - '/tmp/.harness/crash-reports/dropbox-native-crash.txt' + path.join('.harness', 'crash-reports', 'dropbox-native-crash.txt') ); }); diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index 1caf8ec1..44ef0e1a 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -1,4 +1,4 @@ -import { HarnessError } from '@react-native-harness/tools'; +import { formatPath, HarnessError } from '@react-native-harness/tools'; import type { AppCrashDetails } from '@react-native-harness/platforms'; export { StartupStallError, @@ -79,14 +79,14 @@ const buildNativeCrashMessage = ({ lines.push( artifactPath - ? `Harness extracted the crash log: ${artifactPath}` + ? `Harness extracted the crash log: ${formatPath(artifactPath)}` : "Harness couldn't extract the crash log." ); if (enrichmentArtifacts && enrichmentArtifacts.length > 0) { lines.push('Additional crash artifacts:'); for (const artifact of enrichmentArtifacts) { - lines.push(` - ${artifact.artifactPath}`); + lines.push(` - ${formatPath(artifact.artifactPath)}`); } } diff --git a/packages/tools/src/__tests__/path.test.ts b/packages/tools/src/__tests__/path.test.ts new file mode 100644 index 00000000..ab1f7157 --- /dev/null +++ b/packages/tools/src/__tests__/path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { formatPath } from '../path.js'; + +describe('formatPath', () => { + it('formats absolute paths relative to the current working directory', () => { + expect( + formatPath( + path.join('/project', '.harness', 'crash-reports', 'log.txt'), + { + cwd: '/project', + } + ) + ).toBe(path.join('.harness', 'crash-reports', 'log.txt')); + }); + + it('leaves relative paths unchanged', () => { + expect(formatPath(path.join('.harness', 'crash-reports', 'log.txt'))).toBe( + path.join('.harness', 'crash-reports', 'log.txt') + ); + }); + + it('formats the working directory as a dot', () => { + expect(formatPath('/project', { cwd: '/project' })).toBe('.'); + }); +}); diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index a8fc9500..c750a1d6 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -8,6 +8,7 @@ export * from './react-native.js'; export * from './error.js'; export * from './events.js'; export * from './packages.js'; +export * from './path.js'; export * from './crash-artifacts.js'; export * from './harness-artifacts.js'; export * from './regex.js'; diff --git a/packages/tools/src/path.ts b/packages/tools/src/path.ts new file mode 100644 index 00000000..e4002556 --- /dev/null +++ b/packages/tools/src/path.ts @@ -0,0 +1,11 @@ +import path from 'node:path'; + +export type FormatPathOptions = { + cwd?: string; +}; + +export const formatPath = ( + filePath: string, + { cwd = process.cwd() }: FormatPathOptions = {} +) => + path.isAbsolute(filePath) ? path.relative(cwd, filePath) || '.' : filePath; From 89750d2c323db71e92cc186ffc585bce00e21393 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 11:13:23 +0200 Subject: [PATCH 16/17] chore: bring missing files --- .github/workflows/e2e-tests.yml | 491 +++++++++--------- .../src/__tests__/crash-artifacts.test.ts | 43 +- packages/tools/src/crash-artifacts.ts | 41 +- 3 files changed, 308 insertions(+), 267 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9c1ffa4a..107b1260 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -267,242 +267,255 @@ jobs: path: apps/playground/.harness/logs if-no-files-found: ignore - # Temporary disable: crash detector validation jobs. - # crash-validate-android: - # name: Crash Validation Android - # runs-on: ubuntu-22.04 - # timeout-minutes: 30 - # if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} - # env: - # HARNESS_DEBUG: true - # DEBUG: 'Metro:*' - - # steps: - # - name: Checkout code - # uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - # with: - # ref: ${{ github.ref }} - # fetch-depth: 0 - - # - name: Reclaim disk space - # uses: AdityaGarg8/remove-unwanted-software@90e01b21170618765a73370fcc3abbd1684a7793 # v5 - # with: - # remove-dotnet: true - # remove-haskell: true - # remove-codeql: true - # remove-docker-images: true - - # - name: Install pnpm - # uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 - # with: - # version: latest - - # - name: Setup Node.js - # uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - # with: - # node-version: '24.10.0' - # cache: 'pnpm' - - # - name: Install dependencies - # run: | - # pnpm install - - # - name: Build packages - # run: | - # pnpm nx run-many -t build --projects="packages/*" - - # - name: Set up JDK 17 - # uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3.14.1 - # with: - # java-version: '17' - # distribution: 'temurin' - - # - name: Restore APK from cache - # id: cache-apk-restore - # uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - # with: - # path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - # key: apk-playground - - # - name: Build Android app - # if: steps.cache-apk-restore.outputs.cache-hit != 'true' - # working-directory: apps/playground - # run: | - # pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug - - # - name: Save APK to cache - # if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() - # uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - # with: - # path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk - # key: apk-playground - - # - name: Run React Native Harness (expect crash) - # id: crash-test - # continue-on-error: true - # uses: ./ - # with: - # app: android/app/build/outputs/apk/debug/app-debug.apk - # runner: android-crash-pre-rn - # projectRoot: apps/playground - # harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/android' - # preRunHook: | - # echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" - # echo "HARNESS_RUNNER=$HARNESS_RUNNER" - # afterRunHook: | - # echo "HARNESS_RUNNER=$HARNESS_RUNNER" - # echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" - - # - name: Verify crash was detected - # shell: bash - # run: | - # if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then - # echo "ERROR: Expected harness to fail (crash not detected)" - # exit 1 - # fi - # echo "Crash was correctly detected by the harness" - - # - name: Verify crash artifacts exist - # shell: bash - # run: | - # CRASH_DIR="apps/playground/.harness/crash-reports" - # if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then - # echo "Crash report artifacts found:" - # ls -la "$CRASH_DIR" - # else - # echo "ERROR: No crash report artifacts found in $CRASH_DIR" - # exit 1 - # fi - - # - name: Upload Harness logs - # if: always() - # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - # with: - # name: harness-logs-crash-validate-android - # path: apps/playground/.harness/logs - # if-no-files-found: ignore - - # crash-validate-ios: - # name: Crash Validation iOS - # runs-on: macos-latest - # timeout-minutes: 30 - # if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} - # env: - # HARNESS_DEBUG: true - # DEBUG: 'Metro:*' - # steps: - # - name: Checkout code - # uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - # with: - # ref: ${{ github.ref }} - # fetch-depth: 0 - - # - name: Install pnpm - # uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 - # with: - # version: latest - - # - name: Setup Node.js - # uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - # with: - # node-version: '24.10.0' - # cache: 'pnpm' - - # - name: Setup Xcode 26 - # uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - # with: - # xcode-version: '26.0' - - # - name: Install Watchman - # run: brew install watchman - - # - name: Install dependencies - # run: | - # pnpm install - - # - name: Build packages - # run: | - # pnpm nx run-many -t build --projects="packages/*" - - # - name: Restore app from cache - # id: cache-app-restore - # uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - # with: - # path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - # key: ios-app-playground - - # - name: CocoaPods cache - # if: steps.cache-app-restore.outputs.cache-hit != 'true' - # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - # with: - # path: | - # ./apps/playground/ios/Pods - # ~/Library/Caches/CocoaPods - # ~/.cocoapods - # key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} - # restore-keys: | - # playground-${{ runner.os }}-pods- - - # - name: Install CocoaPods - # if: steps.cache-app-restore.outputs.cache-hit != 'true' - # working-directory: apps/playground/ios - # run: | - # pod install - - # - name: Build iOS app - # if: steps.cache-app-restore.outputs.cache-hit != 'true' - # working-directory: apps/playground - # run: | - # pnpm react-native build-ios --buildFolder ./build --verbose - - # - name: Save app to cache - # if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() - # uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - # with: - # path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - # key: ios-app-playground - - # - name: Run React Native Harness (expect crash) - # id: crash-test - # continue-on-error: true - # uses: ./ - # with: - # app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app - # runner: ios-crash-pre-rn - # projectRoot: apps/playground - # harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/ios' - # preRunHook: | - # echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" - # echo "HARNESS_RUNNER=$HARNESS_RUNNER" - # afterRunHook: | - # echo "HARNESS_RUNNER=$HARNESS_RUNNER" - # echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" - - # - name: Verify crash was detected - # shell: bash - # run: | - # if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then - # echo "ERROR: Expected harness to fail (crash not detected)" - # exit 1 - # fi - # echo "Crash was correctly detected by the harness" - - # - name: Verify crash artifacts exist - # shell: bash - # run: | - # CRASH_DIR="apps/playground/.harness/crash-reports" - # if [ -d "$CRASH_DIR" ] && [ "$(ls -A "$CRASH_DIR" 2>/dev/null)" ]; then - # echo "Crash report artifacts found:" - # ls -la "$CRASH_DIR" - # else - # echo "ERROR: No crash report artifacts found in $CRASH_DIR" - # exit 1 - # fi - - # - name: Upload Harness logs - # if: always() - # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - # with: - # name: harness-logs-crash-validate-ios - # path: apps/playground/.harness/logs - # if-no-files-found: ignore + crash-validate-android: + name: Crash Validation Android + runs-on: ubuntu-22.04 + timeout-minutes: 30 + if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android') }} + env: + HARNESS_DEBUG: true + DEBUG: 'Metro:*' + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Reclaim disk space + uses: AdityaGarg8/remove-unwanted-software@90e01b21170618765a73370fcc3abbd1684a7793 # v5 + with: + remove-dotnet: true + remove-haskell: true + remove-codeql: true + remove-docker-images: true + + - name: Install pnpm + uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm install + + - name: Build packages + run: | + pnpm nx run-many -t build --projects="packages/*" + + - name: Set up JDK 17 + uses: actions/setup-java@17f84c3641ba7b8f6deff6309fc4c864478f5d62 # v3.14.1 + with: + java-version: '17' + distribution: 'temurin' + + - name: Restore APK from cache + id: cache-apk-restore + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + key: apk-playground + + - name: Build Android app + if: steps.cache-apk-restore.outputs.cache-hit != 'true' + working-directory: apps/playground + run: | + pnpm nx run @react-native-harness/playground:build-android --tasks=assembleDebug + + - name: Save APK to cache + if: steps.cache-apk-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: apps/playground/android/app/build/outputs/apk/debug/app-debug.apk + key: apk-playground + + - name: Run React Native Harness (expect crash) + id: crash-test + continue-on-error: true + uses: ./ + with: + app: android/app/build/outputs/apk/debug/app-debug.apk + runner: android-crash-pre-rn + projectRoot: apps/playground + harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/android' + preRunHook: | + echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" + echo "HARNESS_RUNNER=$HARNESS_RUNNER" + afterRunHook: | + echo "HARNESS_RUNNER=$HARNESS_RUNNER" + echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + + - name: Verify crash was detected + shell: bash + run: | + if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then + echo "ERROR: Expected harness to fail (crash not detected)" + exit 1 + fi + echo "Crash was correctly detected by the harness" + + - name: Verify crash artifacts exist + shell: bash + run: | + CRASH_DIR="apps/playground/.harness/crash-reports" + if [ ! -d "$CRASH_DIR" ]; then + echo "ERROR: No crash report directory found at $CRASH_DIR" + exit 1 + fi + + ARTIFACT_COUNT="$(find "$CRASH_DIR" -mindepth 4 -type f | wc -l | tr -d ' ')" + if [ "$ARTIFACT_COUNT" -eq 0 ]; then + echo "ERROR: No nested crash report artifacts found in $CRASH_DIR" + find "$CRASH_DIR" -maxdepth 4 -print + exit 1 + fi + + echo "Crash report artifacts found:" + find "$CRASH_DIR" -mindepth 4 -type f -print + + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: harness-logs-crash-validate-android + path: apps/playground/.harness/logs + if-no-files-found: ignore + + crash-validate-ios: + name: Crash Validation iOS + runs-on: macos-latest + timeout-minutes: 30 + if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} + env: + HARNESS_DEBUG: true + DEBUG: 'Metro:*' + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Setup Xcode 26 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: '26.0' + + - name: Install Watchman + run: brew install watchman + + - name: Install dependencies + run: | + pnpm install + + - name: Build packages + run: | + pnpm nx run-many -t build --projects="packages/*" + + - name: Restore app from cache + id: cache-app-restore + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + key: ios-app-playground + + - name: CocoaPods cache + if: steps.cache-app-restore.outputs.cache-hit != 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ./apps/playground/ios/Pods + ~/Library/Caches/CocoaPods + ~/.cocoapods + key: playground-${{ runner.os }}-pods-${{ hashFiles('./apps/playground/ios/Podfile.lock') }} + restore-keys: | + playground-${{ runner.os }}-pods- + + - name: Install CocoaPods + if: steps.cache-app-restore.outputs.cache-hit != 'true' + working-directory: apps/playground/ios + run: | + pod install + + - name: Build iOS app + if: steps.cache-app-restore.outputs.cache-hit != 'true' + working-directory: apps/playground + run: | + pnpm react-native build-ios --buildFolder ./build --verbose + + - name: Save app to cache + if: steps.cache-app-restore.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ./apps/playground/ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + key: ios-app-playground + + - name: Run React Native Harness (expect crash) + id: crash-test + continue-on-error: true + uses: ./ + with: + app: ios/build/Build/Products/Debug-iphonesimulator/HarnessPlayground.app + runner: ios-crash-pre-rn + projectRoot: apps/playground + harnessArgs: '--config jest.harness.crash.config.mjs --testPathPatterns src/__tests__/crash/ios' + preRunHook: | + echo "HARNESS_PROJECT_ROOT=$HARNESS_PROJECT_ROOT" + echo "HARNESS_RUNNER=$HARNESS_RUNNER" + afterRunHook: | + echo "HARNESS_RUNNER=$HARNESS_RUNNER" + echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + + - name: Verify crash was detected + shell: bash + run: | + if [ "${{ steps.crash-test.outcome }}" != "failure" ]; then + echo "ERROR: Expected harness to fail (crash not detected)" + exit 1 + fi + echo "Crash was correctly detected by the harness" + + - name: Verify crash artifacts exist + shell: bash + run: | + CRASH_DIR="apps/playground/.harness/crash-reports" + if [ ! -d "$CRASH_DIR" ]; then + echo "ERROR: No crash report directory found at $CRASH_DIR" + exit 1 + fi + + ARTIFACT_COUNT="$(find "$CRASH_DIR" -mindepth 4 -type f | wc -l | tr -d ' ')" + if [ "$ARTIFACT_COUNT" -eq 0 ]; then + echo "ERROR: No nested crash report artifacts found in $CRASH_DIR" + find "$CRASH_DIR" -maxdepth 4 -print + exit 1 + fi + + echo "Crash report artifacts found:" + find "$CRASH_DIR" -mindepth 4 -type f -print + + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: harness-logs-crash-validate-ios + path: apps/playground/.harness/logs + if-no-files-found: ignore diff --git a/packages/tools/src/__tests__/crash-artifacts.test.ts b/packages/tools/src/__tests__/crash-artifacts.test.ts index 9bd6804c..9d529679 100644 --- a/packages/tools/src/__tests__/crash-artifacts.test.ts +++ b/packages/tools/src/__tests__/crash-artifacts.test.ts @@ -14,7 +14,7 @@ describe('createCrashArtifactWriter', () => { fs.mkdirSync(rootDir, { recursive: true }); }); - it('uses a shared run timestamp and preserves useful file extensions', () => { + it('nests artifacts by run timestamp, runner, and test file', () => { const sourcePath = path.join(rootDir, 'Harness Playground 01.crash'); fs.writeFileSync(sourcePath, 'crash data', 'utf8'); @@ -27,14 +27,23 @@ describe('createCrashArtifactWriter', () => { const persistedPath = writer.persistArtifact({ artifactKind: 'ios-crash-report', + testFilePath: path.join( + process.cwd(), + 'src/__tests__/crash/ios/swift-sync.harness.ts' + ), source: { kind: 'file', path: sourcePath, }, }); - expect(path.basename(persistedPath)).toBe( - '2026-03-12T11-35-08-000Z--ios-simulator--ios--ios-crash-report--Harness-Playground-01.crash' + expect(path.relative(rootDir, persistedPath)).toBe( + path.join( + '2026-03-12T11-35-08-000Z', + 'ios-simulator', + 'src-__tests__-crash-ios-swift-sync.harness.ts', + 'ios--ios-crash-report--Harness-Playground-01.crash' + ) ); expect(fs.readFileSync(persistedPath, 'utf8')).toBe('crash data'); expect(writer.runTimestamp).toBe('2026-03-12T11-35-08-000Z'); @@ -64,7 +73,7 @@ describe('createCrashArtifactWriter', () => { ); }); - it('includes the absolute test path when one is provided', () => { + it('falls back to an absolute test path segment outside the current project', () => { const sourcePath = path.join(rootDir, 'Harness Playground 01.crash'); const testFilePath = path.join(rootDir, 'tests', 'native crash.test.ts'); fs.writeFileSync(sourcePath, 'crash data', 'utf8'); @@ -85,15 +94,17 @@ describe('createCrashArtifactWriter', () => { }, }); - expect(path.basename(persistedPath)).toBe( - `2026-03-12T11-35-08-000Z--ios-simulator--${path - .resolve(testFilePath) - .replace(/[^a-zA-Z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace( - /^-|-$/g, - '' - )}--ios--ios-crash-report--Harness-Playground-01.crash` + expect(path.relative(rootDir, persistedPath)).toBe( + path.join( + '2026-03-12T11-35-08-000Z', + 'ios-simulator', + path + .resolve(testFilePath) + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''), + 'ios--ios-crash-report--Harness-Playground-01.crash' + ) ); }); @@ -124,7 +135,9 @@ describe('createCrashArtifactWriter', () => { }); expect(firstPath).toBe(secondPath); - expect(fs.readdirSync(rootDir)).toHaveLength(2); + expect(fs.readdirSync(path.dirname(firstPath))).toEqual([ + 'ios--ios-crash-report--duplicate.crash', + ]); }); it('keeps artifacts for different test files separate', () => { @@ -156,6 +169,6 @@ describe('createCrashArtifactWriter', () => { }); expect(firstPath).not.toBe(secondPath); - expect(fs.readdirSync(rootDir)).toHaveLength(3); + expect(path.dirname(firstPath)).not.toBe(path.dirname(secondPath)); }); }); diff --git a/packages/tools/src/crash-artifacts.ts b/packages/tools/src/crash-artifacts.ts index cf5c6dc0..57afb389 100644 --- a/packages/tools/src/crash-artifacts.ts +++ b/packages/tools/src/crash-artifacts.ts @@ -17,18 +17,12 @@ const formatRunTimestamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-'); const getTargetFileName = ({ - runTimestamp, - runnerName, platformId, artifactKind, - testFilePath, source, }: { - runTimestamp: string; - runnerName: string; platformId: string; artifactKind: string; - testFilePath?: string; source: | { kind: 'file'; @@ -43,15 +37,31 @@ const getTargetFileName = ({ source.kind === 'file' ? path.basename(source.path) : source.fileName; return [ - sanitizePathSegment(runTimestamp), - sanitizePathSegment(runnerName), - ...(testFilePath ? [sanitizePathSegment(path.resolve(testFilePath))] : []), sanitizePathSegment(platformId), sanitizePathSegment(artifactKind), sanitizePathSegment(originalName), ].join('--'); }; +const getTestFileSegment = (testFilePath?: string) => { + if (!testFilePath) { + return 'unscoped'; + } + + const resolvedTestFilePath = path.resolve(testFilePath); + const relativeTestFilePath = path.relative( + process.cwd(), + resolvedTestFilePath + ); + + return sanitizePathSegment( + relativeTestFilePath.startsWith('..') || + path.isAbsolute(relativeTestFilePath) + ? resolvedTestFilePath + : relativeTestFilePath + ); +}; + const getDeduplicationKey = ({ platformId, artifactKind, @@ -126,18 +136,23 @@ export const createCrashArtifactWriter = ({ fs.mkdirSync(rootDir, { recursive: true }); - const targetPath = path.join( + const targetDir = path.join( rootDir, + sanitizePathSegment(runTimestamp), + sanitizePathSegment(runnerName), + getTestFileSegment(options.testFilePath) + ); + const targetPath = path.join( + targetDir, getTargetFileName({ - runTimestamp, - runnerName, platformId, artifactKind: options.artifactKind, - testFilePath: options.testFilePath, source: options.source, }) ); + fs.mkdirSync(targetDir, { recursive: true }); + if (options.source.kind === 'file') { fs.copyFileSync(options.source.path, targetPath); } else { From cb07c5fe4bac25a41615270630413298eed85ceb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 12:00:29 +0200 Subject: [PATCH 17/17] fix: address crash session review findings Delay iOS exit detection until the app has been observed, filter stale Android Dropbox artifacts, and honor disabled native crash detection during startup readiness. --- .../src/__tests__/harness-session.test.ts | 27 +++++++- packages/jest/src/harness-session.ts | 42 ++++++++++--- .../src/__tests__/crash-diagnostics.test.ts | 11 ++++ .../platform-android/src/crash-diagnostics.ts | 13 +++- .../src/__tests__/app-session.test.ts | 63 +++++++++++++++++++ packages/platform-ios/src/app-session.ts | 5 +- 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 packages/platform-ios/src/__tests__/app-session.test.ts diff --git a/packages/jest/src/__tests__/harness-session.test.ts b/packages/jest/src/__tests__/harness-session.test.ts index 6a261ae4..a86f4364 100644 --- a/packages/jest/src/__tests__/harness-session.test.ts +++ b/packages/jest/src/__tests__/harness-session.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { AppConnection } from '@react-native-harness/bridge/server'; -import { waitForBridgeDisconnectOrTimeout } from '../harness-session.js'; +import { + waitForBridgeDisconnectOrTimeout, + waitForStartupCrash, +} from '../harness-session.js'; +import type { CrashMonitor } from '../crash-monitor.js'; const createConnection = (): AppConnection => ({ device: { @@ -66,3 +70,24 @@ describe('waitForBridgeDisconnectOrTimeout', () => { ).resolves.toBe(false); }); }); + +describe('waitForStartupCrash', () => { + it('does not install a startup crash watch when native crash detection is disabled', async () => { + const watch = vi.fn(); + const crashMonitor = { + watch, + } as unknown as CrashMonitor; + const controller = new AbortController(); + const waitPromise = waitForStartupCrash({ + crashMonitor, + detectNativeCrashes: false, + testFilePath: '/test.harness.ts', + signal: controller.signal, + }); + + controller.abort(new DOMException('Aborted', 'AbortError')); + + await expect(waitPromise).rejects.toThrow('Aborted'); + expect(watch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index 2642ee0d..27be12f9 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -196,9 +196,34 @@ type AppReadyOptions = { readyTimeout: number; maxAppRestarts: number; crashMonitor: CrashMonitor; + detectNativeCrashes: boolean; restartAppSession: () => Promise; }; +export const waitForStartupCrash = async ({ + crashMonitor, + detectNativeCrashes, + testFilePath, + signal, +}: { + crashMonitor: CrashMonitor; + detectNativeCrashes: boolean; + testFilePath: string; + signal: AbortSignal; +}) => { + if (!detectNativeCrashes) { + return await waitForAbort(signal); + } + + const watch = crashMonitor.watch(testFilePath, 'startup'); + watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race + try { + return await Promise.race([watch.promise, waitForAbort(signal)]); + } finally { + watch.cancel(); + } +}; + const waitForAppReady = async ( base: AppReadyOptions, testFilePath: string, @@ -211,6 +236,7 @@ const waitForAppReady = async ( readyTimeout, maxAppRestarts, crashMonitor, + detectNativeCrashes, restartAppSession, } = base; @@ -250,14 +276,13 @@ const waitForAppReady = async ( logWait('runtime ready received'); }, waitForCrash: async (signal) => { - const watch = crashMonitor.watch(testFilePath, 'startup'); - watch.promise.catch(ignorePromiseRejection); // suppress unhandled-rejection when abort wins race - try { - logWait('waiting for crash or runtime ready'); - return await Promise.race([watch.promise, waitForAbort(signal)]); - } finally { - watch.cancel(); - } + logWait('waiting for crash or runtime ready'); + return await waitForStartupCrash({ + crashMonitor, + detectNativeCrashes, + testFilePath, + signal, + }); }, onAttemptStart: () => { logWait('beginning launch attempt for %s', testFilePath); @@ -542,6 +567,7 @@ export const createHarnessSession = async ( readyTimeout: runtimeConfig.bridgeTimeout, maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2, crashMonitor, + detectNativeCrashes: runtimeConfig.detectNativeCrashes !== false, restartAppSession, }; diff --git a/packages/platform-android/src/__tests__/crash-diagnostics.test.ts b/packages/platform-android/src/__tests__/crash-diagnostics.test.ts index 001c5e4d..90ffdc51 100644 --- a/packages/platform-android/src/__tests__/crash-diagnostics.test.ts +++ b/packages/platform-android/src/__tests__/crash-diagnostics.test.ts @@ -59,6 +59,17 @@ describe('collectDropboxArtifacts', () => { processName: 'com.harnessplayground', }); }); + + it('ignores Dropbox entries older than the session start time', async () => { + const artifacts = await collectDropboxArtifacts({ + bundleId: 'com.harnessplayground', + getDropboxOutput: async () => deviceJavaCrashDropbox, + minOccurredAt: Date.parse('2026-05-29T10:22:35.000Z'), + occurredAt: Date.parse('2026-05-29T10:22:36.000Z'), + }); + + expect(artifacts).toHaveLength(0); + }); }); describe('parseDropboxOutput', () => { diff --git a/packages/platform-android/src/crash-diagnostics.ts b/packages/platform-android/src/crash-diagnostics.ts index d3f12ac5..1309c020 100644 --- a/packages/platform-android/src/crash-diagnostics.ts +++ b/packages/platform-android/src/crash-diagnostics.ts @@ -249,6 +249,7 @@ const getMatchingDropboxEntries = ({ bundleId, pid, occurredAt, + minOccurredAt, }: { output: string; bundleId: string; @@ -258,12 +259,21 @@ const getMatchingDropboxEntries = ({ }) => parseDropboxOutput(output) .filter((entry) => matchesDropboxEntry({ entry, bundleId, pid })) + .filter((entry) => { + const entryOccurredAt = parseDropboxTimestamp(entry.timestamp); + + return ( + minOccurredAt === undefined || + entryOccurredAt === 0 || + entryOccurredAt >= minOccurredAt + ); + }) .map((entry) => toDropboxCrashArtifact({ entry, bundleId, pid, - occurredAt, + occurredAt: parseDropboxTimestamp(entry.timestamp) || occurredAt, }) ) .sort((left, right) => { @@ -328,6 +338,7 @@ export const collectDropboxArtifacts = async ({ bundleId, pid: lookup.pid, occurredAt: lookup.occurredAt, + minOccurredAt, }).map((artifact) => persistDropboxArtifact({ artifact, diff --git a/packages/platform-ios/src/__tests__/app-session.test.ts b/packages/platform-ios/src/__tests__/app-session.test.ts new file mode 100644 index 00000000..98f1bf87 --- /dev/null +++ b/packages/platform-ios/src/__tests__/app-session.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Subprocess } from '@react-native-harness/tools'; +import { createIosAppSession } from '../app-session.js'; + +const createPendingLaunchProcess = (): Subprocess => { + let resolveLaunch!: () => void; + const pending = new Promise((resolve) => { + resolveLaunch = resolve; + }); + const child = { + kill: vi.fn(() => { + resolveLaunch(); + return true; + }), + }; + return Object.assign(pending, { + [Symbol.asyncIterator]: () => ({ + next: async () => { + await pending; + return { done: true, value: undefined }; + }, + }), + nodeChildProcess: Promise.resolve(child), + }) as unknown as Subprocess; +}; + +describe('createIosAppSession', () => { + it('does not report an exit before the app has been observed running', async () => { + vi.useFakeTimers(); + + try { + const launchProcess = createPendingLaunchProcess(); + const isAppRunning = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + const sessionPromise = createIosAppSession({ + launch: () => launchProcess, + stopApp: vi.fn(async () => undefined), + isAppRunning, + }); + + await vi.advanceTimersByTimeAsync(100); + const session = await sessionPromise; + const listener = vi.fn(); + session.addListener(listener); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(session.getState()).resolves.toMatchObject({ + status: 'running', + }); + expect(listener).not.toHaveBeenCalled(); + + const disposePromise = session.dispose(); + await vi.advanceTimersByTimeAsync(1000); + await disposePromise; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/platform-ios/src/app-session.ts b/packages/platform-ios/src/app-session.ts index 170e3631..30c69157 100644 --- a/packages/platform-ios/src/app-session.ts +++ b/packages/platform-ios/src/app-session.ts @@ -33,6 +33,7 @@ export const createIosAppSession = async ({ let state: AppSessionState = { status: 'running' }; let disposed = false; let stopPolling = false; + let hasObservedRunning = false; const setExited = (reason: 'observed-exit' | 'process-gone') => { if (disposed || state.status !== 'running') { @@ -74,7 +75,9 @@ export const createIosAppSession = async ({ const pollTask = (async () => { while (!stopPolling) { try { - if (!(await isAppRunning())) { + if (await isAppRunning()) { + hasObservedRunning = true; + } else if (hasObservedRunning) { setExited('process-gone'); return; }