diff --git a/packages/platform-ios/README.md b/packages/platform-ios/README.md index bc4fb32..4046b69 100644 --- a/packages/platform-ios/README.md +++ b/packages/platform-ios/README.md @@ -79,11 +79,10 @@ Creates a physical Apple device configuration. ## Requirements - macOS with Xcode installed -- `libimobiledevice` installed and available in `PATH` (`idevicesyslog`, `idevicecrashreport`, and `idevice_id`) for physical-device crash diagnostics - iOS Simulator or physical device connected - React Native project configured for iOS -Harness uses `simctl` for simulator crash monitoring and `libimobiledevice` for physical-device crash diagnostics. Simulator crash details are best-effort from recent log blocks, while physical devices can additionally attach pulled `.crash` artifacts when available. +Harness uses Apple-native Xcode tooling for crash diagnostics: `simctl diagnose` on simulators and `devicectl` crash log / diagnose flows on physical devices. Native crash monitoring stays internal to the platform package, so the implementation can evolve without changing the public API. ## Made with ❤️ at Callstack diff --git a/packages/platform-ios/src/__tests__/app-monitor.test.ts b/packages/platform-ios/src/__tests__/app-monitor.test.ts index 2b09df7..a7336c7 100644 --- a/packages/platform-ios/src/__tests__/app-monitor.test.ts +++ b/packages/platform-ios/src/__tests__/app-monitor.test.ts @@ -9,7 +9,7 @@ import { } from '../app-monitor.js'; import * as simctl from '../xcrun/simctl.js'; import * as devicectl from '../xcrun/devicectl.js'; -import * as libimobiledevice from '../libimobiledevice.js'; +import * as diagnostics from '../crash-diagnostics.js'; import * as tools from '@react-native-harness/tools'; import { createCrashArtifactWriter } from '@react-native-harness/tools'; import type { Subprocess } from '@react-native-harness/tools'; @@ -30,7 +30,7 @@ const createStreamingSubprocess = ( yield line; } }, - }) as unknown as Subprocess; + } as unknown as Subprocess); const artifactRoot = fs.mkdtempSync( join(tmpdir(), 'rn-harness-ios-monitor-artifacts-') @@ -56,24 +56,6 @@ describe('createUnifiedLogEvent', () => { }); }); - it('detects Swift fatal errors from idevicesyslog with library-qualified process name', () => { - const event = createUnifiedLogEvent({ - line: 'Mar 13 12:27:13.724837 HarnessPlayground(libswiftCore.dylib)[21675] : 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: 21675, - }, - }); - }); - 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', @@ -113,9 +95,9 @@ describe('createIosSimulatorAppMonitor', () => { }); it('starts simctl log stream', async () => { - const spawnSpy = vi.spyOn(tools, 'spawn').mockReturnValue( - createStreamingSubprocess([]) - ); + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockReturnValue(createStreamingSubprocess([])); vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ Bundle: 'com.harnessplayground', @@ -168,7 +150,17 @@ describe('createIosSimulatorAppMonitor', () => { }, ]) ); - vi.spyOn(simctl, 'collectCrashReports').mockResolvedValue([]); + 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', @@ -198,12 +190,6 @@ describe('createIosSimulatorAppMonitor', () => { pid: 1234, exceptionType: 'NSInternalInconsistencyException', }); - expect(details?.artifactType).toBeUndefined(); - expect(details?.artifactPath).toBeUndefined(); - expect(details?.rawLines).toEqual([ - '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException', - '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] *** First throw call stack:', - ]); }); it('prefers a matched simulator crash report when one is found', async () => { @@ -214,30 +200,21 @@ describe('createIosSimulatorAppMonitor', () => { }, ]) ); - const sourcePath = join(artifactRoot, 'HarnessPlayground-2026-03-12-122756.ips'); - fs.writeFileSync(sourcePath, 'simulator crash report', 'utf8'); - vi.spyOn(simctl, 'collectCrashReports').mockImplementation( - async ({ crashArtifactWriter }) => [ - { - artifactType: 'ios-crash-report', - artifactPath: - crashArtifactWriter?.persistArtifact({ - artifactKind: 'ios-crash-report', - source: { - kind: 'file', - path: sourcePath, - }, - }) ?? sourcePath, - occurredAt: Date.now(), - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGTRAP', - exceptionType: 'EXC_BREAKPOINT', - summary: 'simulator crash report', - rawLines: ['simulator crash report'], - }, - ] + 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', @@ -259,145 +236,16 @@ describe('createIosSimulatorAppMonitor', () => { }); await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 10)); - const details = await monitor.getCrashDetails({ pid: 1234, occurredAt: Date.now(), }); - await monitor.stop(); expect(details).toMatchObject({ artifactType: 'ios-crash-report', summary: 'simulator crash report', }); - expect(details?.artifactPath).toContain('/.harness/crash-reports/'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(fs.existsSync(details!.artifactPath!)).toBe(true); - }); - - it('waits for a simulator crash report to appear before falling back to logs', async () => { - vi.spyOn(tools, 'spawn').mockReturnValue( - createStreamingSubprocess([ - { - line: '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', - }); - - let calls = 0; - vi.spyOn(simctl, 'collectCrashReports').mockImplementation(async () => { - calls += 1; - - if (calls === 1) { - return []; - } - - return [ - { - artifactType: 'ios-crash-report', - artifactPath: '/tmp/HarnessPlayground.ips', - occurredAt: Date.now(), - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGTRAP', - exceptionType: 'EXC_BREAKPOINT', - stackTrace: ['0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)'], - rawLines: ['simulator crash report'], - }, - ]; - }); - - const monitor = createIosSimulatorAppMonitor({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - }); - - await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const details = await monitor.getCrashDetails({ - pid: 1234, - occurredAt: Date.now(), - }); - - await monitor.stop(); - - expect(calls).toBe(2); - expect(details).toMatchObject({ - artifactType: 'ios-crash-report', - stackTrace: ['0 AppDelegate.crashIfRequested() (AppDelegate.swift:31)'], - }); - }); - - it('does not emit generic simulator log noise', async () => { - vi.spyOn(tools, 'spawn').mockReturnValue( - createStreamingSubprocess([ - { - line: '2026-03-12 11:35:08.000 runningboardd[55:aaaa] Acquiring assertion for com.harnessplayground', - }, - { - line: '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] app-specific log line', - delayMs: 10, - }, - ]) - ); - vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({ - Bundle: 'com.harnessplayground', - CFBundleIdentifier: 'com.harnessplayground', - CFBundleExecutable: 'HarnessPlayground', - CFBundleName: 'HarnessPlayground', - CFBundleDisplayName: 'Harness Playground', - Path: '/tmp/HarnessPlayground.app', - }); - - const lines: string[] = []; - const monitor = createIosSimulatorAppMonitor({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - }); - monitor.addListener((event) => { - if (event.type === 'log') { - lines.push(event.line); - } - }); - - await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 25)); - await monitor.stop(); - - expect(lines).toEqual([ - '2026-03-12 11:35:08.010 HarnessPlayground[1234:abcd] app-specific log line', - ]); - }); - - it('cleans up the background simctl process on stop', async () => { - const kill = vi.fn(); - vi.spyOn(tools, 'spawn').mockReturnValue({ - nodeChildProcess: Promise.resolve({ kill }), - // eslint-disable-next-line @typescript-eslint/no-empty-function - [Symbol.asyncIterator]: async function* () {}, - } as unknown as Subprocess); - vi.spyOn(simctl, 'getAppInfo').mockResolvedValue(null); - - const monitor = createIosSimulatorAppMonitor({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - }); - - await monitor.start(); - await monitor.stop(); - - expect(kill).toHaveBeenCalled(); }); }); @@ -406,59 +254,28 @@ describe('createIosDeviceAppMonitor', () => { vi.restoreAllMocks(); }); - it('uses libimobiledevice for physical device log streaming', async () => { - const syslogSpy = vi - .spyOn(libimobiledevice, 'createSyslogProcess') - .mockReturnValue(createStreamingSubprocess([])); - const targetSpy = vi - .spyOn(libimobiledevice, 'assertLibimobiledeviceTargetAvailable') - .mockResolvedValue(undefined); + 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', }); - - const monitor = createIosDeviceAppMonitor({ - deviceId: 'device-udid', - libimobiledeviceUdid: 'hardware-udid', - bundleId: 'com.harnessplayground', - }); - - await monitor.start(); - await monitor.stop(); - - expect(targetSpy).toHaveBeenCalledWith('hardware-udid'); - expect(syslogSpy).toHaveBeenCalledWith({ - targetId: 'hardware-udid', - processNames: ['com.harnessplayground', 'HarnessPlayground'], - }); - }); - - it('detects idevicesyslog crash lines with library-qualified process names', async () => { - vi.spyOn(libimobiledevice, 'assertLibimobiledeviceTargetAvailable').mockResolvedValue( - undefined - ); - vi.spyOn(libimobiledevice, 'createSyslogProcess').mockReturnValue( - createStreamingSubprocess([ + vi.spyOn(diagnostics, 'collectCrashArtifacts').mockResolvedValue([]); + const getProcesses = vi + .spyOn(devicectl, 'getProcesses') + .mockResolvedValueOnce([ { - line: 'Mar 13 12:27:13.724837 HarnessPlayground(libswiftCore.dylib)[21675] : HarnessPlayground/AppDelegate.swift:31: Fatal error: Intentional pre-RN startup crash', + executable: '/private/var/HarnessPlayground.app/HarnessPlayground', + processIdentifier: 4321, }, ]) - ); - vi.spyOn(libimobiledevice, 'collectCrashReports').mockResolvedValue([]); - vi.spyOn(devicectl, 'getAppInfo').mockResolvedValue({ - bundleIdentifier: 'com.harnessplayground', - name: 'HarnessPlayground', - version: '1.0', - url: '/private/var/HarnessPlayground.app', - }); + .mockResolvedValueOnce([]) + .mockResolvedValue([]); const events: Array<{ type: string }> = []; const monitor = createIosDeviceAppMonitor({ deviceId: 'device-udid', - libimobiledeviceUdid: 'hardware-udid', bundleId: 'com.harnessplayground', }); monitor.addListener((event) => { @@ -466,68 +283,38 @@ describe('createIosDeviceAppMonitor', () => { }); await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 10)); - - const details = await monitor.getCrashDetails({ - pid: 21675, - occurredAt: Date.now(), - }); - + await new Promise((resolve) => setTimeout(resolve, 1200)); await monitor.stop(); - expect(events.some((event) => event.type === 'possible_crash')).toBe(true); - expect(details).toMatchObject({ - source: 'logs', - processName: 'HarnessPlayground', - pid: 21675, - }); + expect(getProcesses).toHaveBeenCalled(); + expect(events.some((event) => event.type === 'app_exited')).toBe(true); }); - it('still enriches device crashes with pulled crash reports', async () => { - vi.spyOn(libimobiledevice, 'assertLibimobiledeviceTargetAvailable').mockResolvedValue( - undefined - ); - vi.spyOn(libimobiledevice, 'createSyslogProcess').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.crash'); - fs.writeFileSync(sourcePath, 'full crash report', 'utf8'); - vi.spyOn(libimobiledevice, 'collectCrashReports').mockImplementation( - async ({ crashArtifactWriter }) => [ - { - artifactType: 'ios-crash-report', - artifactPath: - crashArtifactWriter?.persistArtifact({ - artifactKind: 'ios-crash-report', - source: { - kind: 'file', - path: sourcePath, - }, - }) ?? sourcePath, - occurredAt: Date.now(), - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGABRT', - exceptionType: 'NSInternalInconsistencyException', - summary: 'full crash report', - rawLines: ['full crash report'], - }, - ] - ); + 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', - libimobiledeviceUdid: 'hardware-udid', bundleId: 'com.harnessplayground', crashArtifactWriter: createCrashArtifactWriter({ runnerName: 'ios-device', @@ -538,21 +325,15 @@ describe('createIosDeviceAppMonitor', () => { }); await monitor.start(); - await new Promise((resolve) => setTimeout(resolve, 10)); - const details = await monitor.getCrashDetails({ pid: 1234, occurredAt: Date.now(), }); - await monitor.stop(); expect(details).toMatchObject({ artifactType: 'ios-crash-report', summary: 'full crash report', }); - expect(details?.artifactPath).toContain('/.harness/crash-reports/'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(fs.existsSync(details!.artifactPath!)).toBe(true); }); }); diff --git a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts new file mode 100644 index 0000000..21ba567 --- /dev/null +++ b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createCrashArtifactWriter } from '@react-native-harness/tools'; +import { collectCrashArtifacts } from '../crash-diagnostics.js'; +import * as simctl from '../xcrun/simctl.js'; +import * as devicectl from '../xcrun/devicectl.js'; + +describe('collectCrashArtifacts', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('collects simulator crash artifacts from simctl diagnose output', async () => { + const outputRoot = fs.mkdtempSync( + join(tmpdir(), 'rn-harness-simctl-diagnose-'), + ); + const crashPath = join(outputRoot, 'HarnessPlayground.ips'); + fs.writeFileSync( + crashPath, + [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + timestamp: '2026-03-12 11:35:08 +0000', + }), + JSON.stringify({ + pid: 1234, + procName: 'HarnessPlayground', + procPath: + '/Users/me/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground', + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n'), + '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({ + targetId: 'sim-udid', + targetType: 'simulator', + processNames: ['HarnessPlayground'], + bundleId: 'com.harnessplayground', + minOccurredAt: Date.parse('2026-03-12T11:35:07.000Z'), + }); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]).toMatchObject({ + artifactType: 'ios-crash-report', + processName: 'HarnessPlayground', + pid: 1234, + exceptionType: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + targetId: 'sim-udid', + }); + }); + + it('collects device crash artifacts from systemCrashLogs before falling back to diagnose', async () => { + const outputRoot = fs.mkdtempSync( + join(tmpdir(), 'rn-harness-devicectl-crash-logs-'), + ); + const crashPath = join(outputRoot, 'HarnessPlayground.crash'); + fs.writeFileSync( + crashPath, + [ + 'Process: HarnessPlayground [4321]', + 'Identifier: com.harnessplayground', + 'Date/Time: 2026-03-12 11:35:08 +0000', + 'Exception Type: EXC_CRASH (SIGABRT)', + ].join('\n'), + 'utf8', + ); + + vi.spyOn(devicectl, 'listFiles').mockResolvedValue([ + '/systemCrashLogs/HarnessPlayground-2026-03-12-113508.crash', + ]); + vi.spyOn(devicectl, 'copyFileFrom').mockImplementation( + async (_deviceId, options) => { + fs.copyFileSync(crashPath, options.destination); + }, + ); + const diagnoseSpy = vi + .spyOn(devicectl, 'diagnose') + .mockResolvedValue(undefined); + + const artifacts = await collectCrashArtifacts({ + targetId: 'device-udid', + targetType: 'device', + processNames: ['HarnessPlayground'], + bundleId: 'com.harnessplayground', + minOccurredAt: Date.parse('2026-03-12T11:35:07.000Z'), + }); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0]).toMatchObject({ + processName: 'HarnessPlayground', + pid: 4321, + bundleId: 'com.harnessplayground', + signal: 'SIGABRT', + }); + expect(diagnoseSpy).not.toHaveBeenCalled(); + }); + + it('persists matched crash artifacts with the provided writer', async () => { + const sourceRoot = fs.mkdtempSync( + join(tmpdir(), 'rn-harness-crash-diagnostics-'), + ); + const sourcePath = join(sourceRoot, 'HarnessPlayground.ips'); + fs.writeFileSync( + sourcePath, + [ + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + timestamp: '2026-03-12 11:35:08 +0000', + }), + JSON.stringify({ + pid: 1234, + procName: 'HarnessPlayground', + procPath: + '/Users/me/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground', + exception: { + type: 'EXC_BREAKPOINT', + signal: 'SIGTRAP', + }, + }), + ].join('\n'), + 'utf8', + ); + + vi.spyOn(simctl, 'diagnose').mockImplementation( + async (_udid, outputDir) => { + fs.mkdirSync(outputDir, { recursive: true }); + fs.copyFileSync(sourcePath, join(outputDir, 'HarnessPlayground.ips')); + }, + ); + + const writer = createCrashArtifactWriter({ + runnerName: 'ios-sim', + platformId: 'ios', + rootDir: join(sourceRoot, '.harness', 'crash-reports'), + runTimestamp: '2026-03-12T11-35-08-000Z', + }); + + const artifacts = await collectCrashArtifacts({ + targetId: 'sim-udid', + targetType: 'simulator', + processNames: ['HarnessPlayground'], + bundleId: 'com.harnessplayground', + crashArtifactWriter: writer, + }); + + expect(artifacts[0]?.artifactPath).toContain('/.harness/crash-reports/'); + expect(fs.existsSync(artifacts[0]?.artifactPath ?? '')).toBe(true); + }); +}); diff --git a/packages/platform-ios/src/__tests__/crash-parser.test.ts b/packages/platform-ios/src/__tests__/crash-parser.test.ts index bbadefd..04c55a1 100644 --- a/packages/platform-ios/src/__tests__/crash-parser.test.ts +++ b/packages/platform-ios/src/__tests__/crash-parser.test.ts @@ -49,10 +49,13 @@ describe('iosCrashParser.parse', () => { app_name: 'HarnessPlayground', bundleID: 'com.harnessplayground', name: 'HarnessPlayground', + timestamp: '2026-03-12 11:35:08 +0000', }), JSON.stringify({ pid: 1234, procName: 'HarnessPlayground', + procPath: + '/Users/me/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground', faultingThread: 0, threads: [ { @@ -75,11 +78,13 @@ describe('iosCrashParser.parse', () => { ].join('\n'), }) ).toMatchObject({ - occurredAt: 7890, + occurredAt: Date.parse('2026-03-12T11:35:08.000Z'), signal: 'SIGTRAP', exceptionType: 'EXC_BREAKPOINT', + bundleId: 'com.harnessplayground', processName: 'HarnessPlayground', pid: 1234, + targetId: 'sim-udid', }); statSpy.mockRestore(); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index e3816f7..8867f6c 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -9,7 +9,6 @@ import { } from '../instance.js'; import * as simctl from '../xcrun/simctl.js'; import * as devicectl from '../xcrun/devicectl.js'; -import * as libimobiledevice from '../libimobiledevice.js'; import { HarnessAppPathError } from '../errors.js'; import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -33,10 +32,7 @@ describe('iOS platform instance dependency validation', () => { vi.unstubAllEnvs(); }); - it('does not validate libimobiledevice before creating a simulator instance', async () => { - const assertInstalled = vi - .spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled') - .mockResolvedValue(undefined); + it('does not require extra dependencies before creating a simulator instance', async () => { vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); @@ -57,62 +53,22 @@ describe('iOS platform instance dependency validation', () => { await expect( getAppleSimulatorPlatformInstance(config, harnessConfig, init) ).resolves.toBeDefined(); - expect(assertInstalled).not.toHaveBeenCalled(); - }); - - it('validates libimobiledevice before creating a physical device instance when native crash detection is enabled', async () => { - const assertInstalled = vi - .spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled') - .mockRejectedValue(new Error('missing')); - - const config = { - name: 'ios-device', - device: { type: 'physical' as const, name: 'My iPhone' }, - bundleId: 'com.harnessplayground', - }; - - await expect( - getApplePhysicalDevicePlatformInstance(config, harnessConfig) - ).rejects.toThrow('missing'); - expect(assertInstalled).toHaveBeenCalled(); }); - it('still discovers the simulator without libimobiledevice', async () => { - vi.spyOn( - libimobiledevice, - 'assertLibimobiledeviceInstalled' - ).mockResolvedValue(undefined); - const getSimulatorId = vi - .spyOn(simctl, 'getSimulatorId') - .mockResolvedValue('sim-udid'); - vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); - vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); - vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined - ); - - const config = { - name: 'ios', - device: { - type: 'simulator' as const, - name: 'iPhone 16 Pro', - systemVersion: '18.0', + it('discovers the physical device directly through devicectl', async () => { + const getDevice = vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ + identifier: 'physical-device-id', + deviceProperties: { + name: 'My iPhone', + osVersionNumber: '18.0', }, - bundleId: 'com.harnessplayground', - }; - - await expect( - getAppleSimulatorPlatformInstance(config, harnessConfig, init) - ).resolves.toBeDefined(); - expect(getSimulatorId).toHaveBeenCalled(); - }); - - it('does not try to discover the physical device when the dependency is missing and native crash detection is enabled', async () => { - vi.spyOn( - libimobiledevice, - 'assertLibimobiledeviceInstalled' - ).mockRejectedValue(new Error('missing')); - const getDeviceId = vi.spyOn(devicectl, 'getDeviceId'); + hardwareProperties: { + marketingName: 'iPhone', + productType: 'iPhone17,1', + udid: '00008140-001600222422201C', + }, + }); + vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); const config = { name: 'ios-device', @@ -122,14 +78,11 @@ describe('iOS platform instance dependency validation', () => { await expect( getApplePhysicalDevicePlatformInstance(config, harnessConfig) - ).rejects.toThrow('missing'); - expect(getDeviceId).not.toHaveBeenCalled(); + ).resolves.toBeDefined(); + expect(getDevice).toHaveBeenCalledWith('My iPhone'); }); - it('skips libimobiledevice validation when native crash detection is disabled', async () => { - const assertInstalled = vi - .spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled') - .mockRejectedValue(new Error('missing')); + it('skips physical crash monitoring setup when native crash detection is disabled', async () => { vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'physical-device-id', deviceProperties: { @@ -156,7 +109,6 @@ describe('iOS platform instance dependency validation', () => { harnessConfigWithoutNativeCrashDetection ) ).resolves.toBeDefined(); - expect(assertInstalled).not.toHaveBeenCalled(); }); it('returns a noop simulator app monitor when native crash detection is disabled', async () => { diff --git a/packages/platform-ios/src/__tests__/libimobiledevice.test.ts b/packages/platform-ios/src/__tests__/libimobiledevice.test.ts deleted file mode 100644 index 50b91d7..0000000 --- a/packages/platform-ios/src/__tests__/libimobiledevice.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import * as tools from '@react-native-harness/tools'; -import { createCrashArtifactWriter } from '@react-native-harness/tools'; -import { - assertLibimobiledeviceInstalled, - collectCrashReports, -} from '../libimobiledevice.js'; - -describe('assertLibimobiledeviceInstalled', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('passes when all required binaries are present', async () => { - vi.spyOn(tools, 'spawn').mockResolvedValue({ - stdout: '/opt/homebrew/bin/tool\n', - } as Awaited>); - - await expect(assertLibimobiledeviceInstalled()).resolves.toBeUndefined(); - }); - - it('throws when any required binary is missing', async () => { - const spawnSpy = vi.spyOn(tools, 'spawn'); - - spawnSpy - .mockResolvedValueOnce({ - stdout: '/opt/homebrew/bin/idevicesyslog\n', - } as Awaited>) - .mockRejectedValueOnce(new Error('missing')); - - await expect(assertLibimobiledeviceInstalled()).rejects.toMatchObject({ - name: 'DependencyNotFoundError', - dependencyName: 'libimobiledevice', - }); - }); -}); - -describe('collectCrashReports', () => { - const workDir = fs.mkdtempSync(join(tmpdir(), 'rn-harness-ios-crash-tests-')); - const artifactRoot = fs.mkdtempSync(join(tmpdir(), 'rn-harness-ios-crash-artifacts-')); - - afterEach(() => { - vi.restoreAllMocks(); - fs.rmSync(workDir, { recursive: true, force: true }); - fs.mkdirSync(workDir, { recursive: true }); - fs.rmSync(artifactRoot, { recursive: true, force: true }); - fs.mkdirSync(artifactRoot, { recursive: true }); - }); - - it('extracts matching crash reports with artifact metadata', async () => { - vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); - const spawnSpy = vi.spyOn(tools, 'spawn').mockImplementation( - (async (file: string, args?: readonly string[]) => { - if (file === 'idevicecrashreport') { - const targetDir = args?.[args.length - 1]; - - if (!targetDir) { - throw new Error('missing target dir'); - } - - fs.writeFileSync( - join(targetDir, 'HarnessPlayground-2026-03-12-113508.crash'), - [ - 'Process: HarnessPlayground [1234]', - 'Identifier: com.harnessplayground', - 'Exception Type: EXC_CRASH (SIGABRT)', - 'Triggered by Thread: 0', - '', - 'Thread 0 Crashed:', - '0 HarnessPlayground 0x0000000100000000 AppDelegate.crashIfRequested() + 20', - '1 HarnessPlayground 0x0000000100000014 AppDelegate.application(_:didFinishLaunchingWithOptions:) + 40', - '', - ].join('\n') - ); - } - - return { - stdout: '', - } as Awaited>; - }) as typeof tools.spawn - ); - - const reports = await collectCrashReports({ - targetId: 'device-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - }); - - expect(spawnSpy).toHaveBeenCalledWith('idevicecrashreport', [ - '-u', - 'device-udid', - '--keep', - '--extract', - '--filter', - 'HarnessPlayground', - expect.any(String), - ]); - expect(reports).toHaveLength(1); - expect(reports[0]).toMatchObject({ - artifactType: 'ios-crash-report', - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGABRT', - exceptionType: 'EXC_CRASH (SIGABRT)', - stackTrace: [ - '0 HarnessPlayground 0x0000000100000000 AppDelegate.crashIfRequested() + 20', - '1 HarnessPlayground 0x0000000100000014 AppDelegate.application(_:didFinishLaunchingWithOptions:) + 40', - ], - }); - }); - - it('filters by executable name rather than bundle id', async () => { - vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); - const spawnSpy = vi.spyOn(tools, 'spawn').mockResolvedValue({ - stdout: '', - } as Awaited>); - - await collectCrashReports({ - targetId: 'device-udid', - bundleId: 'com.harnessplayground', - processNames: ['com.harnessplayground', 'HarnessPlayground'], - }); - - expect(spawnSpy).toHaveBeenCalledWith('idevicecrashreport', [ - '-u', - 'device-udid', - '--keep', - '--extract', - '--filter', - 'HarnessPlayground', - expect.any(String), - ]); - }); - - it('parses .ips crash reports from the device', async () => { - vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); - vi.spyOn(tools, 'spawn').mockImplementation( - (async (file: string, args?: readonly string[]) => { - if (file === 'idevicecrashreport') { - const targetDir = args?.[args.length - 1]; - - if (!targetDir) { - throw new Error('missing target dir'); - } - - const header = JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - }); - const body = JSON.stringify({ - pid: 21675, - procName: 'HarnessPlayground', - faultingThread: 0, - exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP' }, - threads: [ - { - frames: [ - { imageIndex: 0, symbol: 'AppDelegate.crashIfRequested()', symbolLocation: 20 }, - ], - }, - ], - usedImages: [{ name: 'HarnessPlayground' }], - }); - - fs.writeFileSync( - join(targetDir, 'HarnessPlayground-2026-03-12-113508.ips'), - `${header}\n${body}` - ); - } - - return { - stdout: '', - } as Awaited>; - }) as typeof tools.spawn - ); - - const reports = await collectCrashReports({ - targetId: 'device-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - }); - - expect(reports).toHaveLength(1); - expect(reports[0]).toMatchObject({ - artifactType: 'ios-crash-report', - processName: 'HarnessPlayground', - pid: 21675, - signal: 'SIGTRAP', - exceptionType: 'EXC_BREAKPOINT', - stackTrace: ['0 AppDelegate.crashIfRequested() (+ 20)'], - }); - }); - - it('persists pulled crash reports before temporary cleanup', async () => { - vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); - vi.spyOn(tools, 'spawn').mockImplementation( - (async (file: string, args?: readonly string[]) => { - if (file === 'idevicecrashreport') { - const targetDir = args?.[args.length - 1]; - - if (!targetDir) { - throw new Error('missing target dir'); - } - - fs.writeFileSync( - join(targetDir, 'HarnessPlayground-2026-03-12-113508.crash'), - [ - 'Process: HarnessPlayground [1234]', - 'Identifier: com.harnessplayground', - 'Exception Type: EXC_CRASH (SIGABRT)', - ].join('\n') - ); - } - - return { - stdout: '', - } as Awaited>; - }) as typeof tools.spawn - ); - const crashReportDir = join(artifactRoot, '.harness', 'crash-reports'); - const writer = createCrashArtifactWriter({ - runnerName: 'ios-device', - platformId: 'ios', - rootDir: crashReportDir, - runTimestamp: '2026-03-12T11-35-08-000Z', - }); - - const reports = await collectCrashReports({ - targetId: 'device-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - crashArtifactWriter: writer, - }); - - expect(reports[0]?.artifactPath).toContain('/.harness/crash-reports/'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(fs.existsSync(reports[0]!.artifactPath)).toBe(true); - expect(fs.existsSync(workDir)).toBe(false); - }); - - it('returns an empty list when no matching crash reports are found', async () => { - vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); - vi.spyOn(tools, 'spawn').mockResolvedValue({ - stdout: '', - } as Awaited>); - - const reports = await collectCrashReports({ - targetId: 'device-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - }); - - expect(reports).toEqual([]); - }); - - it('ignores crash reports older than the current run window', async () => { - vi.spyOn(fs, 'mkdtempSync').mockReturnValue(workDir); - vi.spyOn(tools, 'spawn').mockImplementation( - (async (file: string, args?: readonly string[]) => { - if (file === 'idevicecrashreport') { - const targetDir = args?.[args.length - 1]; - - if (!targetDir) { - throw new Error('missing target dir'); - } - - fs.writeFileSync( - join(targetDir, 'old.crash'), - [ - 'Process: HarnessPlayground [1234]', - 'Identifier: com.harnessplayground', - 'Date/Time: 2026-03-12 11:30:08.000 +0000', - ].join('\n') - ); - fs.writeFileSync( - join(targetDir, 'new.crash'), - [ - 'Process: HarnessPlayground [1235]', - 'Identifier: com.harnessplayground', - 'Date/Time: 2026-03-12 11:40:08.000 +0000', - ].join('\n') - ); - } - - return { - stdout: '', - } as Awaited>; - }) as typeof tools.spawn - ); - - const reports = await collectCrashReports({ - targetId: 'device-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - minOccurredAt: Date.parse('2026-03-12T11:35:08.000Z'), - }); - - expect(reports).toHaveLength(1); - expect(reports[0]?.pid).toBe(1235); - }); -}); diff --git a/packages/platform-ios/src/__tests__/simctl.test.ts b/packages/platform-ios/src/__tests__/simctl.test.ts index 2f179d1..a56b90a 100644 --- a/packages/platform-ios/src/__tests__/simctl.test.ts +++ b/packages/platform-ios/src/__tests__/simctl.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; -import { join } from 'node:path'; -import { homedir, tmpdir } from 'node:os'; -import { createCrashArtifactWriter } from '@react-native-harness/tools'; import * as tools from '@react-native-harness/tools'; -import { collectCrashReports, waitForBoot } from '../xcrun/simctl.js'; +import { diagnose, waitForBoot } from '../xcrun/simctl.js'; describe('simctl startup', () => { beforeEach(() => { @@ -22,254 +18,30 @@ describe('simctl startup', () => { expect(spawnSpy).toHaveBeenCalledWith( 'xcrun', ['simctl', 'bootstatus', 'sim-udid', '-b'], - { signal } + { signal }, ); }); -}); - -describe('simctl collectCrashReports', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - it('extracts matching simulator .ips crash reports by filename prefix', async () => { - const diagnosticReportsDir = join( - homedir(), - 'Library', - 'Logs', - 'DiagnosticReports' - ); - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - // OtherApp file is present but must be ignored purely based on filename prefix - vi.spyOn(fs, 'readdirSync').mockReturnValue([ - 'HarnessPlayground-2026-03-12-122756.ips', - 'OtherApp-2026-03-12-122756.ips', - ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - [ - JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - name: 'HarnessPlayground', - }), - JSON.stringify({ - pid: 1234, - procName: 'HarnessPlayground', - procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, - faultingThread: 0, - threads: [ - { - frames: [ - { - symbol: '_assertionFailure(_:_:file:line:flags:)', - symbolLocation: 156, - imageIndex: 1, - }, - { - symbol: 'AppDelegate.crashIfRequested()', - sourceFile: 'AppDelegate.swift', - sourceLine: 31, - imageIndex: 1, - }, - ], - }, - ], - usedImages: [{ name: 'dyld' }, { name: 'HarnessPlayground' }], - exception: { - type: 'EXC_BREAKPOINT', - signal: 'SIGTRAP', - }, - }), - ].join('\n') as ReturnType - ); - vi.spyOn(fs, 'statSync').mockReturnValue({ - mtimeMs: 123456, - } as fs.Stats); + it('runs simctl diagnose into the provided directory', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); - const reports = await collectCrashReports({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - }); + await diagnose('sim-udid', '/tmp/sim-diagnose-output'); - expect(reports).toEqual([ + expect(spawnSpy).toHaveBeenCalledWith( + 'xcrun', + [ + 'simctl', + 'diagnose', + '--udid=sim-udid', + '--no-archive', + '--output=/tmp/sim-diagnose-output', + '-b', + ], { - artifactType: 'ios-crash-report', - artifactPath: join( - diagnosticReportsDir, - 'HarnessPlayground-2026-03-12-122756.ips' - ), - occurredAt: 123456, - processName: 'HarnessPlayground', - pid: 1234, - signal: 'SIGTRAP', - exceptionType: 'EXC_BREAKPOINT', - stackTrace: [ - '0 _assertionFailure(_:_:file:line:flags:) (+ 156)', - '1 AppDelegate.crashIfRequested() (AppDelegate.swift:31)', - ], - rawLines: expect.any(Array), + stdin: { string: '\n' }, }, - ]); - }); - - it('copies matched simulator reports into .harness when a writer is provided', async () => { - const tempRoot = fs.mkdtempSync( - join(tmpdir(), 'rn-harness-simctl-artifacts-') - ); - const artifactRoot = join(tempRoot, '.harness', 'crash-reports'); - const diagnosticReportsDir = join( - homedir(), - 'Library', - 'Logs', - 'DiagnosticReports' ); - - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readdirSync').mockReturnValue([ - 'HarnessPlayground-2026-03-12-122756.ips', - ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - [ - JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - name: 'HarnessPlayground', - }), - JSON.stringify({ - pid: 1234, - procName: 'HarnessPlayground', - procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, - exception: { - type: 'EXC_BREAKPOINT', - signal: 'SIGTRAP', - }, - }), - ].join('\n') as ReturnType - ); - vi.spyOn(fs, 'statSync').mockReturnValue({ - mtimeMs: 123456, - } as fs.Stats); - const copyFileSyncSpy = vi - .spyOn(fs, 'copyFileSync') - .mockImplementation(() => undefined); - const writer = createCrashArtifactWriter({ - runnerName: 'ios-sim', - platformId: 'ios', - rootDir: artifactRoot, - runTimestamp: '2026-03-12T11-35-08-000Z', - }); - - const reports = await collectCrashReports({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - crashArtifactWriter: writer, - }); - - expect(reports[0]?.artifactPath).toContain('/.harness/crash-reports/'); - expect(copyFileSyncSpy).toHaveBeenCalledWith( - join(diagnosticReportsDir, 'HarnessPlayground-2026-03-12-122756.ips'), - reports[0]?.artifactPath - ); - }); - - it('ignores simulator reports older than the current run window', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readdirSync').mockReturnValue([ - 'HarnessPlayground-2026-03-12-113008.ips', - 'HarnessPlayground-2026-03-12-114008.ips', - ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockImplementation((( - input: fs.PathOrFileDescriptor - ) => { - const filePath = String(input); - - return [ - JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - name: 'HarnessPlayground', - }), - JSON.stringify({ - pid: filePath.includes('113008') ? 1234 : 1235, - procName: 'HarnessPlayground', - procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, - exception: { - type: 'EXC_BREAKPOINT', - signal: 'SIGTRAP', - }, - }), - ].join('\n'); - }) as typeof fs.readFileSync); - vi.spyOn(fs, 'statSync').mockImplementation(((input: fs.PathLike) => ({ - mtimeMs: String(input).includes('113008') - ? Date.parse('2026-03-12T11:30:08.000Z') - : Date.parse('2026-03-12T11:40:08.000Z'), - })) as typeof fs.statSync); - - const reports = await collectCrashReports({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - minOccurredAt: Date.parse('2026-03-12T11:35:08.000Z'), - }); - - expect(reports).toHaveLength(1); - expect(reports[0]?.pid).toBe(1235); - }); - - it('returns the latest crash report that matches the simulator udid, skipping newer ones from other simulators', async () => { - vi.spyOn(fs, 'existsSync').mockReturnValue(true); - vi.spyOn(fs, 'readdirSync').mockReturnValue([ - 'HarnessPlayground-2026-03-12-110000.ips', - 'HarnessPlayground-2026-03-12-120000.ips', - 'HarnessPlayground-2026-03-12-130000.ips', - ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockImplementation((( - input: fs.PathOrFileDescriptor - ) => { - const filePath = String(input); - // The newest file (130000) belongs to a different simulator; the second-newest (120000) is ours - const udid = filePath.includes('130000') ? 'other-sim-udid' : 'sim-udid'; - const pid = filePath.includes('110000') - ? 1001 - : filePath.includes('120000') - ? 1002 - : 1003; - - return [ - JSON.stringify({ - app_name: 'HarnessPlayground', - bundleID: 'com.harnessplayground', - }), - JSON.stringify({ - pid, - procName: 'HarnessPlayground', - procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/${udid}/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, - exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP' }, - }), - ].join('\n'); - }) as typeof fs.readFileSync); - vi.spyOn(fs, 'statSync').mockImplementation(((input: fs.PathLike) => { - const filePath = String(input); - const mtimeMs = filePath.includes('110000') - ? Date.parse('2026-03-12T11:00:00.000Z') - : filePath.includes('120000') - ? Date.parse('2026-03-12T12:00:00.000Z') - : Date.parse('2026-03-12T13:00:00.000Z'); - - return { mtimeMs } as fs.Stats; - }) as typeof fs.statSync); - - const reports = await collectCrashReports({ - udid: 'sim-udid', - bundleId: 'com.harnessplayground', - processNames: ['HarnessPlayground'], - }); - - expect(reports).toHaveLength(1); - // Skips the newest (pid 1003, other simulator) and returns the second-newest that matches - expect(reports[0]?.pid).toBe(1002); }); }); diff --git a/packages/platform-ios/src/app-monitor.ts b/packages/platform-ios/src/app-monitor.ts index 1ecd88d..6c29259 100644 --- a/packages/platform-ios/src/app-monitor.ts +++ b/packages/platform-ios/src/app-monitor.ts @@ -6,18 +6,26 @@ import { type CrashArtifactWriter, type CrashDetailsLookupOptions, } from '@react-native-harness/platforms'; -import { escapeRegExp, getEmitter, logger, spawn, type Subprocess } from '@react-native-harness/tools'; +import { + escapeRegExp, + getEmitter, + logger, + spawn, + type Subprocess, +} from '@react-native-harness/tools'; import * as devicectl from './xcrun/devicectl.js'; import * as simctl from './xcrun/simctl.js'; -import * as libimobiledevice from './libimobiledevice.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 = 100; -const CRASH_ARTIFACT_WAIT_TIMEOUT_MS = 10000; -const CRASH_ARTIFACT_POLL_INTERVAL_MS = 1000; +const CRASH_ARTIFACT_SETTLE_DELAY_MS = 300; +const APP_EXIT_POLL_INTERVAL_MS = 1000; type TimedLogLine = { line: string; @@ -58,7 +66,11 @@ const getProcessName = (line: string, processNames: string[]) => const getPid = (line: string, processNames: string[]) => { for (const processName of processNames) { const match = line.match( - new RegExp(`\\b${escapeRegExp(processName)}(?:\\([^)]*\\))?\\[(\\d+)(?::[^\\]]+)?\\]`) + new RegExp( + `\\b${escapeRegExp( + processName + )}(?:\\([^)]*\\))?\\[(\\d+)(?::[^\\]]+)?\\]` + ) ); if (match) { @@ -148,9 +160,10 @@ const createAppMonitorBase = () => { }; const recordLogLine = (line: string) => { - recentLogLines = [...recentLogLines, { line, occurredAt: Date.now() }].slice( - -MAX_RECENT_LOG_LINES - ); + recentLogLines = [ + ...recentLogLines, + { line, occurredAt: Date.now() }, + ].slice(-MAX_RECENT_LOG_LINES); }; const recordCrashArtifact = (details: AppCrashDetails) => { @@ -171,18 +184,17 @@ const createAppMonitorBase = () => { : []; const matchingByProcess = options.processName ? recentCrashArtifacts.filter( - (artifact) => artifact.processName === options.processName - ) + (artifact) => artifact.processName === options.processName + ) : []; const candidates = matchingByPid.length > 0 ? matchingByPid : matchingByProcess.length > 0 - ? matchingByProcess - : recentCrashArtifacts; + ? matchingByProcess + : recentCrashArtifacts; const preferredCandidates = candidates.filter( - (artifact) => - artifact.artifactType === 'ios-crash-report' + (artifact) => artifact.artifactType === 'ios-crash-report' ); const prioritizedCandidates = preferredCandidates.length > 0 ? preferredCandidates : candidates; @@ -298,6 +310,7 @@ const createAppMonitorBase = () => { return { createLifecycle, + emit, handleLogEvent, recordCrashArtifact, getLatestCrashArtifact, @@ -320,6 +333,84 @@ const getRecentLogBlock = ({ 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, @@ -338,11 +429,13 @@ export const createIosSimulatorAppMonitor = ({ 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)))]; + processNames = [ + ...new Set( + [appInfo?.CFBundleExecutable, appInfo?.CFBundleName, bundleId].filter( + (value): value is string => Boolean(value) + ) + ), + ]; const predicate = processNames .map((name) => `process == "${name}"`) @@ -397,238 +490,130 @@ export const createIosSimulatorAppMonitor = ({ await currentTask; }; - const waitForCrashArtifact = async ( - options: CrashDetailsLookupOptions - ): Promise => { - let fallbackArtifact: AppCrashDetails | null = null; - const deadline = Date.now() + CRASH_ARTIFACT_WAIT_TIMEOUT_MS; - let pollCount = 0; - - do { - pollCount += 1; - iosAppMonitorLogger.debug('waitForCrashArtifact poll #%d %o', pollCount, { - pid: options.pid, - processName: options.processName, - }); - - const collectedArtifacts = await simctl.collectCrashReports({ - udid, - bundleId, - processNames, - crashArtifactWriter, - minOccurredAt: monitorStartedAt, - }); - - iosAppMonitorLogger.debug( - 'poll #%d collected %d crash artifact(s) from DiagnosticReports', - pollCount, - collectedArtifacts.length - ); - - for (const artifact of collectedArtifacts) { - base.recordCrashArtifact(artifact); - } - - const artifact = base.getLatestCrashArtifact(options); - - if (artifact) { - iosAppMonitorLogger.debug('poll #%d found artifact %o', pollCount, { - artifactType: artifact.artifactType, - artifactPath: artifact.artifactPath, - pid: artifact.pid, - processName: artifact.processName, - }); - - if (artifact.artifactType === 'ios-crash-report') { - return artifact; - } - - fallbackArtifact = artifact; - } else { - iosAppMonitorLogger.debug( - 'poll #%d found no matching crash artifact yet', - pollCount - ); - } - - if (Date.now() >= deadline) { - iosAppMonitorLogger.debug( - 'waitForCrashArtifact deadline reached, returning %s', - fallbackArtifact ? 'fallback log-based artifact' : 'null' - ); - return fallbackArtifact; - } - - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS) - ); - // eslint-disable-next-line no-constant-condition - } while (true); - }; - return base.createLifecycle({ startLogMonitor, stopLogMonitor, - getCrashDetails: async (options) => { - iosAppMonitorLogger.debug('getCrashDetails called for simulator: %o', { - pid: options.pid, - processName: options.processName, - }); - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) - ); - - const artifact = await waitForCrashArtifact(options); - - if (!artifact) { - iosAppMonitorLogger.debug('getCrashDetails found no artifact'); - return null; - } - - if (artifact.artifactType === 'ios-crash-report') { - iosAppMonitorLogger.debug( - 'getCrashDetails returning ios-crash-report artifact: %s', - artifact.artifactPath - ); - return artifact; - } - - const relatedLogLines = getRecentLogBlock({ - recentLogLines: base.getRecentLogLines(), - occurredAt: options.occurredAt, - }); - - iosAppMonitorLogger.debug( - 'getCrashDetails returning log-based artifact with %d related log lines', - relatedLogLines.length - ); - - return { - ...artifact, - summary: - relatedLogLines.length > 0 - ? relatedLogLines.join('\n') - : artifact.summary, - rawLines: - relatedLogLines.length > 0 ? relatedLogLines : artifact.rawLines, - artifactType: undefined, - artifactPath: undefined, - }; - }, + getCrashDetails: (options) => + createCrashDetailsLookup({ + targetId: udid, + targetType: 'simulator', + bundleId, + processNames, + monitorStartedAt, + crashArtifactWriter, + base, + })(options), }); }; export const createIosDeviceAppMonitor = ({ deviceId, - libimobiledeviceUdid, bundleId, crashArtifactWriter, }: { deviceId: string; - libimobiledeviceUdid: string; bundleId: string; crashArtifactWriter?: CrashArtifactWriter; }): IosAppMonitor => { const base = createAppMonitorBase(); - let logProcess: Subprocess | null = null; - let logTask: Promise | null = null; - let processNames = [bundleId]; + 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 = [bundleId, appInfo?.name].filter( - (value): value is string => Boolean(value) - ); - - await libimobiledevice.assertLibimobiledeviceTargetAvailable(libimobiledeviceUdid); - logProcess = libimobiledevice.createSyslogProcess({ - targetId: libimobiledeviceUdid, - processNames, - }); - - const currentProcess = logProcess; - - if (!currentProcess) { - return; - } - - logTask = (async () => { - try { - for await (const line of currentProcess) { - base.handleLogEvent(line, processNames); + 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); } - } catch (error) { - iosAppMonitorLogger.debug( - 'iOS libimobiledevice log monitor stopped', - error + + await new Promise((resolve) => + setTimeout(resolve, APP_EXIT_POLL_INTERVAL_MS) ); } })(); - }; - - const stopLogMonitor = async () => { - const currentProcess = logProcess; - const currentTask = logTask; - logProcess = null; - logTask = null; + const initialArtifacts = await collectCrashArtifacts({ + targetId: deviceId, + targetType: 'device', + bundleId, + processNames, + crashArtifactWriter, + minOccurredAt: monitorStartedAt, + }); - await base.stopProcess(currentProcess); - await currentTask; + for (const artifact of initialArtifacts) { + base.recordCrashArtifact(artifact); + } }; - const waitForCrashArtifact = async ( - options: CrashDetailsLookupOptions - ): Promise => { - let fallbackArtifact: AppCrashDetails | null = null; - const deadline = Date.now() + CRASH_ARTIFACT_WAIT_TIMEOUT_MS; - - do { - const collectedArtifacts = await libimobiledevice.collectCrashReports({ - targetId: libimobiledeviceUdid, - bundleId, - processNames, - crashArtifactWriter, - minOccurredAt: monitorStartedAt, - }); - - for (const artifact of collectedArtifacts) { - base.recordCrashArtifact(artifact); - } - - const artifact = base.getLatestCrashArtifact(options); - - if (artifact) { - if (artifact.artifactType === 'ios-crash-report') { - return artifact; - } - - fallbackArtifact = artifact; - } - - if (Date.now() >= deadline) { - return fallbackArtifact; - } - - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS) - ); - // eslint-disable-next-line no-constant-condition - } while (true); + const stopLogMonitor = async () => { + stopPolling = true; + await pollTask; + pollTask = null; }; return base.createLifecycle({ startLogMonitor, stopLogMonitor, - getCrashDetails: async (options) => { - await new Promise((resolve) => - setTimeout(resolve, CRASH_ARTIFACT_SETTLE_DELAY_MS) - ); - - return waitForCrashArtifact(options); - }, + getCrashDetails: (options) => + createCrashDetailsLookup({ + targetId: deviceId, + targetType: 'device', + bundleId, + processNames, + monitorStartedAt, + crashArtifactWriter, + base, + })(options), }); }; diff --git a/packages/platform-ios/src/crash-diagnostics.ts b/packages/platform-ios/src/crash-diagnostics.ts new file mode 100644 index 0000000..1c8ab1b --- /dev/null +++ b/packages/platform-ios/src/crash-diagnostics.ts @@ -0,0 +1,368 @@ +import type { + AppCrashDetails, + CrashArtifactWriter, + CrashDetailsLookupOptions, +} from '@react-native-harness/platforms'; +import { logger } from '@react-native-harness/tools'; +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { iosCrashParser } from './crash-parser.js'; +import * as devicectl from './xcrun/devicectl.js'; +import * as simctl from './xcrun/simctl.js'; + +const crashDiagnosticsLogger = logger.child('ios-crash-diagnostics'); + +const CRASH_ARTIFACT_WAIT_TIMEOUT_MS = 30000; +const CRASH_ARTIFACT_POLL_INTERVAL_MS = 1500; + +type CollectIosCrashArtifactsOptions = { + processNames: string[]; + bundleId: string; + crashArtifactWriter?: CrashArtifactWriter; + minOccurredAt?: number; +}; + +type CollectSimulatorCrashArtifactsOptions = CollectIosCrashArtifactsOptions & { + targetType: 'simulator'; + targetId: string; +}; + +type CollectPhysicalCrashArtifactsOptions = CollectIosCrashArtifactsOptions & { + targetType: 'device'; + targetId: string; +}; + +type CollectCrashArtifactsOptions = + | CollectSimulatorCrashArtifactsOptions + | CollectPhysicalCrashArtifactsOptions; + +type DiagnosedCrashArtifact = AppCrashDetails & { + artifactType: 'ios-crash-report'; + artifactPath: string; + occurredAt: number; + bundleId?: string; + targetId?: string; + score?: number; +}; + +type WaitForCrashArtifactOptions = { + lookup: CrashDetailsLookupOptions; + options: CollectCrashArtifactsOptions; + getFallbackArtifact: () => AppCrashDetails | null; + recordArtifact: (artifact: AppCrashDetails) => void; +}; + +const isCrashReportFile = (path: string) => + path.endsWith('.ips') || path.endsWith('.crash'); + +const collectFilesRecursively = (rootDir: string): string[] => { + if (!fs.existsSync(rootDir)) { + return []; + } + + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(rootDir, entry.name); + + if (entry.isDirectory()) { + files.push(...collectFilesRecursively(fullPath)); + continue; + } + + if (entry.isFile()) { + files.push(fullPath); + } + } + + return files; +}; + +const createTempDirectory = (prefix: string) => { + const path = join(tmpdir(), `${prefix}-${randomUUID()}`); + fs.mkdirSync(path, { recursive: true }); + return path; +}; + +const scoreCrashArtifact = ({ + artifact, + options, + lookup, +}: { + artifact: DiagnosedCrashArtifact; + options: CollectCrashArtifactsOptions; + lookup?: CrashDetailsLookupOptions; +}) => { + let score = 0; + + if (options.processNames.includes(artifact.processName ?? '')) { + score += 40; + } + + if (artifact.bundleId === options.bundleId) { + score += 30; + } + + if (lookup?.pid !== undefined && artifact.pid === lookup.pid) { + score += 100; + } + + if (lookup?.processName && artifact.processName === lookup.processName) { + score += 80; + } + + if (artifact.targetId === options.targetId) { + score += 50; + } + + const referenceTime = lookup?.occurredAt ?? options.minOccurredAt; + + if (referenceTime !== undefined) { + const distance = Math.abs(artifact.occurredAt - referenceTime); + + if (distance <= 5_000) { + score += 40; + } else if (distance <= 30_000) { + score += 20; + } else if (distance <= 120_000) { + score += 5; + } + } + + return score; +}; + +const getBestMatchingArtifact = ({ + artifacts, + options, + lookup, +}: { + artifacts: DiagnosedCrashArtifact[]; + options: CollectCrashArtifactsOptions; + lookup: CrashDetailsLookupOptions; +}) => { + const scoredArtifacts = artifacts + .map((artifact) => ({ + artifact, + score: scoreCrashArtifact({ artifact, options, lookup }), + })) + .filter((entry) => entry.score > 0) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + + return right.artifact.occurredAt - left.artifact.occurredAt; + }); + + return scoredArtifacts[0]?.artifact ?? null; +}; + +const parseCrashArtifacts = ({ + rootDir, + options, + lookup, +}: { + rootDir: string; + options: CollectCrashArtifactsOptions; + lookup?: CrashDetailsLookupOptions; +}): DiagnosedCrashArtifact[] => { + const candidates = collectFilesRecursively(rootDir) + .filter(isCrashReportFile) + .map((path) => { + const contents = fs.readFileSync(path, 'utf8'); + const parsed = iosCrashParser.parse({ path, contents }); + + if (!parsed) { + return null; + } + + if ( + options.minOccurredAt !== undefined && + parsed.occurredAt < options.minOccurredAt + ) { + return null; + } + + const artifactPath = options.crashArtifactWriter + ? options.crashArtifactWriter.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { + kind: 'file', + path, + }, + }) + : path; + + const artifact: DiagnosedCrashArtifact = { + ...parsed, + artifactType: 'ios-crash-report', + artifactPath, + occurredAt: parsed.occurredAt, + }; + + artifact.score = scoreCrashArtifact({ artifact, options, lookup }); + return artifact; + }) + .filter((artifact): artifact is DiagnosedCrashArtifact => + Boolean(artifact), + ); + + return candidates.sort((left, right) => { + if ((right.score ?? 0) !== (left.score ?? 0)) { + return (right.score ?? 0) - (left.score ?? 0); + } + + return right.occurredAt - left.occurredAt; + }); +}; + +const collectSimulatorCrashArtifacts = async ({ + targetId, + ...options +}: CollectSimulatorCrashArtifactsOptions) => { + const outputDir = createTempDirectory('rn-harness-simctl-diagnose'); + + try { + await simctl.diagnose(targetId, outputDir); + return parseCrashArtifacts({ + rootDir: outputDir, + options: { ...options, targetId, targetType: 'simulator' }, + }); + } finally { + fs.rmSync(outputDir, { recursive: true, force: true }); + } +}; + +const collectPhysicalCrashArtifacts = async ({ + targetId, + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, +}: CollectPhysicalCrashArtifactsOptions) => { + const crashLogsDir = createTempDirectory('rn-harness-devicectl-crash-logs'); + + try { + const remoteCrashLogPaths = await devicectl.listFiles(targetId, { + domainType: 'systemCrashLogs', + recursive: true, + }); + const filteredCrashLogPaths = remoteCrashLogPaths.filter((remotePath) => + processNames.some((processName) => remotePath.includes(processName)), + ); + + if (filteredCrashLogPaths.length > 0) { + for (const remotePath of filteredCrashLogPaths) { + const fileName = remotePath.split('/').pop(); + + if (!fileName) { + continue; + } + + await devicectl.copyFileFrom(targetId, { + source: remotePath, + destination: join(crashLogsDir, fileName), + domainType: 'systemCrashLogs', + }); + } + + const copiedArtifacts = parseCrashArtifacts({ + rootDir: crashLogsDir, + options: { + targetId, + targetType: 'device', + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, + }, + }); + + if (copiedArtifacts.length > 0) { + return copiedArtifacts; + } + } + + const outputDir = createTempDirectory('rn-harness-devicectl-diagnose'); + + try { + await devicectl.diagnose(targetId, outputDir); + return parseCrashArtifacts({ + rootDir: outputDir, + options: { + targetId, + targetType: 'device', + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, + }, + }); + } finally { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + } finally { + fs.rmSync(crashLogsDir, { recursive: true, force: true }); + } +}; + +export const collectCrashArtifacts = async ( + options: CollectCrashArtifactsOptions, +): Promise => { + crashDiagnosticsLogger.debug('collecting crash artifacts: %o', { + targetId: options.targetId, + targetType: options.targetType, + processNames: options.processNames, + minOccurredAt: options.minOccurredAt, + }); + + if (options.targetType === 'simulator') { + return collectSimulatorCrashArtifacts(options); + } + + return collectPhysicalCrashArtifacts(options); +}; + +export const waitForCrashArtifact = async ({ + lookup, + options, + getFallbackArtifact, + recordArtifact, +}: WaitForCrashArtifactOptions): Promise => { + const deadline = Date.now() + CRASH_ARTIFACT_WAIT_TIMEOUT_MS; + let fallbackArtifact = getFallbackArtifact(); + + while (Date.now() < deadline) { + const artifacts = await collectCrashArtifacts(options); + + for (const artifact of artifacts) { + recordArtifact(artifact); + } + + const matchingArtifact = getBestMatchingArtifact({ + artifacts, + options, + lookup, + }); + + if (matchingArtifact) { + return matchingArtifact; + } + + fallbackArtifact = getFallbackArtifact(); + + if (Date.now() >= deadline) { + return fallbackArtifact; + } + + await new Promise((resolve) => + setTimeout(resolve, CRASH_ARTIFACT_POLL_INTERVAL_MS), + ); + } + + return getFallbackArtifact() ?? fallbackArtifact; +}; diff --git a/packages/platform-ios/src/crash-parser.ts b/packages/platform-ios/src/crash-parser.ts index 8dbe0dc..491a85b 100644 --- a/packages/platform-ios/src/crash-parser.ts +++ b/packages/platform-ios/src/crash-parser.ts @@ -8,6 +8,41 @@ type ParseIosCrashReportOptions = { export type ParsedIosCrashReport = AppCrashDetails & { occurredAt: number; + bundleId?: string; + procPath?: string; + targetId?: string; +}; + +const parseDateValue = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value !== 'string' || value.trim().length === 0) { + return undefined; + } + + const normalizedValue = value + .trim() + .replace(/^(\d{4}-\d{2}-\d{2})\s+/, '$1T') + .replace(/\s+([+-]\d{2})(\d{2})$/, '$1:$2'); + const parsedDate = Date.parse(normalizedValue); + + return Number.isNaN(parsedDate) ? undefined : parsedDate; +}; + +const getTargetIdFromProcPath = (procPath?: string) => { + if (!procPath) { + return undefined; + } + + const simulatorMatch = procPath.match(/CoreSimulator\/Devices\/([^/]+)\//); + + if (simulatorMatch) { + return simulatorMatch[1]; + } + + return undefined; }; const getSignal = (contents: string) => { @@ -26,20 +61,13 @@ const getSignal = (contents: string) => { return undefined; }; -const getOccurredAt = ({ - path, - contents, -}: ParseIosCrashReportOptions) => { +const getOccurredAt = ({ path, contents }: ParseIosCrashReportOptions) => { const dateTimeMatch = contents.match(/^Date\/Time:\s+(.+)$/m); if (dateTimeMatch) { - const normalizedValue = dateTimeMatch[1] - .trim() - .replace(/^(\d{4}-\d{2}-\d{2})\s+/, '$1T') - .replace(/\s+([+-]\d{2})(\d{2})$/, '$1:$2'); - const parsedDate = Date.parse(normalizedValue); + const parsedDate = parseDateValue(dateTimeMatch[1]); - if (!Number.isNaN(parsedDate)) { + if (parsedDate !== undefined) { return parsedDate; } } @@ -49,7 +77,9 @@ const getOccurredAt = ({ const getCrashThreadFrames = (rawLines: string[], threadId: string) => { const threadHeader = `Thread ${threadId} Crashed:`; - const threadHeaderIndex = rawLines.findIndex((line) => line.trim() === threadHeader); + const threadHeaderIndex = rawLines.findIndex( + (line) => line.trim() === threadHeader + ); if (threadHeaderIndex === -1) { return undefined; @@ -87,11 +117,14 @@ const parseCrashTextReport = ({ const rawLines = contents.split(/\r?\n/); const processMatch = contents.match(/^Process:\s+(.+?)\s+\[(\d+)\]$/m); const exceptionMatch = contents.match(/^Exception Type:\s+(.+)$/m); - const triggeredThreadMatch = contents.match(/^Triggered by Thread:\s+(\d+)$/m); + const triggeredThreadMatch = contents.match( + /^Triggered by Thread:\s+(\d+)$/m + ); return { occurredAt: getOccurredAt({ path, contents }), rawLines, + bundleId: contents.match(/^Identifier:\s+(.+)$/m)?.[1]?.trim(), processName: processMatch?.[1]?.trim(), pid: processMatch ? Number(processMatch[2]) : undefined, signal: getSignal(contents), @@ -117,10 +150,14 @@ const parseIpsCrashReport = ({ app_name?: string; bundleID?: string; name?: string; + timestamp?: string; }; const body = JSON.parse(bodyLines.join('\n')) as { + captureTime?: string; pid?: number; procName?: string; + procPath?: string; + procLaunch?: string; faultingThread?: number; threads?: Array<{ frames?: Array<{ @@ -157,10 +194,10 @@ const parseIpsCrashReport = ({ frame.sourceFile && frame.sourceLine ? `${frame.sourceFile}:${frame.sourceLine}` : frame.symbolLocation !== undefined - ? `+ ${frame.symbolLocation}` - : frame.imageOffset !== undefined - ? `+ ${frame.imageOffset}` - : undefined; + ? `+ ${frame.symbolLocation}` + : frame.imageOffset !== undefined + ? `+ ${frame.imageOffset}` + : undefined; const symbol = frame.symbol ?? imageName ?? ''; return `${index} ${symbol}${location ? ` (${location})` : ''}`; @@ -168,13 +205,22 @@ const parseIpsCrashReport = ({ .filter((line) => line.trim().length > 0); return { - occurredAt: fs.statSync(path).mtimeMs, + occurredAt: + parseDateValue(header.timestamp) ?? + parseDateValue(body.captureTime) ?? + parseDateValue(body.procLaunch) ?? + fs.statSync(path).mtimeMs, rawLines: contents.split(/\r?\n/), + bundleId: header.bundleID, processName: body.procName ?? header.app_name ?? header.name, pid: body.pid, + procPath: body.procPath, + targetId: getTargetIdFromProcPath(body.procPath), signal: body.exception?.signal ?? getSignal(contents), exceptionType: - body.exception?.type ?? body.termination?.indicator ?? getSignal(contents), + body.exception?.type ?? + body.termination?.indicator ?? + getSignal(contents), stackTrace: stackTrace.length > 0 ? stackTrace : undefined, }; } catch { diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index f5c52a7..554ebb1 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -22,7 +22,6 @@ import { createIosDeviceAppMonitor, createIosSimulatorAppMonitor, } from './app-monitor.js'; -import { assertLibimobiledeviceInstalled } from './libimobiledevice.js'; import { HarnessAppPathError } from './errors.js'; import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; @@ -175,10 +174,6 @@ export const getApplePhysicalDevicePlatformInstance = async ( assertAppleDevicePhysical(config.device); const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; - if (detectNativeCrashes) { - await assertLibimobiledeviceInstalled(); - } - 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}.` @@ -192,7 +187,6 @@ export const getApplePhysicalDevicePlatformInstance = async ( } const deviceId = device.identifier; - const hardwareUdid = device.hardwareProperties.udid; const isAvailable = await devicectl.isAppInstalled(deviceId, config.bundleId); @@ -237,7 +231,6 @@ export const getApplePhysicalDevicePlatformInstance = async ( return createIosDeviceAppMonitor({ deviceId, - libimobiledeviceUdid: hardwareUdid, bundleId: config.bundleId, crashArtifactWriter: options?.crashArtifactWriter, }); diff --git a/packages/platform-ios/src/libimobiledevice.ts b/packages/platform-ios/src/libimobiledevice.ts deleted file mode 100644 index 399cf18..0000000 --- a/packages/platform-ios/src/libimobiledevice.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { - DependencyNotFoundError, - type CrashArtifactWriter, -} from '@react-native-harness/platforms'; -import { escapeRegExp, spawn, type Subprocess } from '@react-native-harness/tools'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { iosCrashParser } from './crash-parser.js'; - -const REQUIRED_BINARIES = [ - 'idevicesyslog', - 'idevicecrashreport', - 'idevice_id', -] as const; - -const INSTALL_INSTRUCTIONS = - 'Install libimobiledevice and ensure idevicesyslog, idevicecrashreport, and idevice_id are available in PATH.'; - -export type IosCrashArtifact = { - artifactType: 'ios-crash-report'; - artifactPath: string; - summary?: string; - rawLines: string[]; - processName?: string; - pid?: number; - signal?: string; - exceptionType?: string; - stackTrace?: string[]; - occurredAt: number; -}; - -const shouldIncludeCrashReport = ({ - path, - contents, - bundleId, - processNames, -}: { - path: string; - contents: string; - bundleId: string; - processNames: string[]; -}) => { - if (contents.includes(bundleId) || path.includes(bundleId)) { - return true; - } - - return processNames.some((processName) => { - const processPattern = new RegExp(`\\b${escapeRegExp(processName)}\\b`); - - return processPattern.test(contents) || processPattern.test(path); - }); -}; - -export const assertLibimobiledeviceInstalled = async (): Promise => { - for (const binary of REQUIRED_BINARIES) { - try { - await spawn('which', [binary]); - } catch { - throw new DependencyNotFoundError('libimobiledevice', INSTALL_INSTRUCTIONS); - } - } -}; - -export const assertLibimobiledeviceTargetAvailable = async ( - targetId: string -): Promise => { - try { - await spawn('idevicesyslog', ['-u', targetId, 'pidlist']); - } catch (error) { - throw new Error( - `libimobiledevice could not attach to iOS target "${targetId}". ${error instanceof Error ? error.message : ''}`.trim() - ); - } -}; - -export const createSyslogProcess = ({ - targetId, - processNames, -}: { - targetId: string; - processNames: string[]; -}): Subprocess => - spawn( - 'idevicesyslog', - ['-u', targetId, '--exit', '--process', processNames.join('|')], - { - stdout: 'pipe', - stderr: 'pipe', - } - ); - -const getCrashReportFilterName = ( - processNames: string[], - bundleId: string -) => processNames.find((name) => name !== bundleId) ?? processNames[0]; - -const isCrashReportFile = (entry: string) => - entry.endsWith('.crash') || entry.endsWith('.ips'); - -export const collectCrashReports = async ({ - targetId, - bundleId, - processNames, - crashArtifactWriter, - minOccurredAt, -}: { - targetId: string; - bundleId: string; - processNames: string[]; - crashArtifactWriter?: CrashArtifactWriter; - minOccurredAt?: number; -}): Promise => { - const crashDir = fs.mkdtempSync(join(tmpdir(), 'rn-harness-ios-crashes-')); - - try { - const filterName = getCrashReportFilterName(processNames, bundleId); - - await spawn('idevicecrashreport', [ - '-u', - targetId, - '--keep', - '--extract', - ...(filterName ? ['--filter', filterName] : []), - crashDir, - ]); - - const reportPaths = fs - .readdirSync(crashDir) - .filter(isCrashReportFile) - .map((entry) => join(crashDir, entry)); - - return reportPaths - .map((path) => ({ - path, - contents: fs.readFileSync(path, 'utf8'), - })) - .filter(({ path, contents }) => - shouldIncludeCrashReport({ - path, - contents, - bundleId, - processNames, - }) - ) - .map(({ path, contents }) => { - const report = iosCrashParser.parse({ - path, - contents, - }); - - if (!report) { - return null; - } - - if (minOccurredAt !== undefined && report.occurredAt < minOccurredAt) { - return null; - } - - if (!crashArtifactWriter) { - return { - artifactType: 'ios-crash-report', - artifactPath: path, - ...report, - }; - } - - return { - artifactType: 'ios-crash-report', - ...report, - artifactPath: crashArtifactWriter.persistArtifact({ - artifactKind: 'ios-crash-report', - source: { - kind: 'file', - path, - }, - }), - }; - }) - .filter((report): report is IosCrashArtifact => report !== null); - } finally { - fs.rmSync(crashDir, { recursive: true, force: true }); - } -}; diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index 2bbd0b0..85d958e 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -21,11 +21,7 @@ export const devicectl = async ( ...args.slice(separatorIndex), ]; - await spawn('xcrun', [ - 'devicectl', - command, - ...argsWithJsonOutput, - ]); + await spawn('xcrun', ['devicectl', command, ...argsWithJsonOutput]); if (!fs.existsSync(tempFile)) { throw new Error(`devicectl did not produce JSON output at ${tempFile}`); @@ -64,6 +60,17 @@ export type AppleAppInfo = { url: string; }; +type DevicectlFileInfo = { + path?: string; + filePath?: string; + relativePath?: string; + name?: string; +}; + +const getDevicectlPath = (file: DevicectlFileInfo): string | null => { + return file.path ?? file.filePath ?? file.relativePath ?? file.name ?? null; +}; + export const listApps = async (identifier: string): Promise => { const result = await devicectl<{ apps: AppleAppInfo[] }>('device', [ 'info', @@ -103,7 +110,10 @@ export const startApp = async ( bundleId: string, options?: AppleAppLaunchOptions ): Promise => { - await devicectl('device', getDeviceCtlLaunchArgs(identifier, bundleId, options)); + await devicectl( + 'device', + getDeviceCtlLaunchArgs(identifier, bundleId, options) + ); }; export const getDeviceCtlLaunchArgs = ( @@ -143,6 +153,76 @@ export const getProcesses = async ( return result.runningProcesses; }; +export const listFiles = async ( + identifier: string, + options: { + domainType: 'systemCrashLogs'; + recursive?: boolean; + subdirectory?: string; + } +): Promise => { + const args = [ + 'info', + 'files', + '--device', + identifier, + '--domain-type', + options.domainType, + ]; + + if (options.subdirectory) { + args.push('--subdirectory', options.subdirectory); + } + + args.push(options.recursive === false ? '--no-recurse' : '--recurse'); + + const result = await devicectl<{ + items?: DevicectlFileInfo[]; + files?: DevicectlFileInfo[]; + }>('device', args); + const items = result.items ?? result.files ?? []; + + return items + .map(getDevicectlPath) + .filter((path): path is string => Boolean(path)); +}; + +export const copyFileFrom = async ( + identifier: string, + options: { + source: string; + destination: string; + domainType: 'systemCrashLogs'; + } +): Promise => { + await devicectl('device', [ + 'copy', + 'from', + '--device', + identifier, + '--source', + options.source, + '--destination', + options.destination, + '--domain-type', + options.domainType, + ]); +}; + +export const diagnose = async ( + identifier: string, + outputDir: string +): Promise => { + await devicectl('diagnose', [ + '--devices', + identifier, + '--no-archive', + '--archive-destination', + outputDir, + '--keep-temp-dir', + ]); +}; + export const stopApp = async ( identifier: string, bundleId: string diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index 195c784..536a04b 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -16,12 +16,12 @@ import { iosCrashParser } from '../crash-parser.js'; const simctlLogger = logger.child('simctl'); const plistToJson = async ( - plistOutput: string + plistOutput: string, ): Promise> => { const { stdout: jsonOutput } = await spawn( 'plutil', ['-convert', 'json', '-o', '-', '-'], - { stdin: { string: plistOutput } } + { stdin: { string: plistOutput } }, ); return JSON.parse(jsonOutput) as Record; }; @@ -83,16 +83,16 @@ export const collectCrashReports = async ({ simctlLogger.debug( 'found %d total entries and %d .ips files in DiagnosticReports', allEntries.length, - ipsEntries.length + ipsEntries.length, ); // Crash files are named {ProcessName}-YYYY-MM-DD-HHMMSS.ips, so filter by filename prefix. const matchingEntries = ipsEntries.filter((entry) => - processNames.some((name) => entry.startsWith(`${name}-`)) + processNames.some((name) => entry.startsWith(`${name}-`)), ); simctlLogger.debug( '%d crash report file(s) match process names by filename prefix', - matchingEntries.length + matchingEntries.length, ); type CrashCandidate = AppleSimulatorCrashReport & { contents: string }; @@ -113,7 +113,7 @@ export const collectCrashReports = async ({ 'skipping %s: occurredAt=%d is older than minOccurredAt=%d', entry, report.occurredAt, - minOccurredAt + minOccurredAt, ); continue; } @@ -145,7 +145,7 @@ export const collectCrashReports = async ({ simctlLogger.debug( 'skipping candidate occurredAt=%d: report does not contain udid %s', candidate.occurredAt, - udid + udid, ); continue; } @@ -180,7 +180,7 @@ export const collectCrashReports = async ({ export const getAppInfo = async ( udid: string, - bundleId: string + bundleId: string, ): Promise => { const { stdout: plistOutput } = await spawn('xcrun', [ 'simctl', @@ -203,7 +203,7 @@ export const getAppInfo = async ( export const isAppInstalled = async ( udid: string, - bundleId: string + bundleId: string, ): Promise => { const appInfo = await getAppInfo(udid, bundleId); return appInfo !== null; @@ -219,7 +219,7 @@ export const isBootedSimulatorStatus = (status: AppleSimulatorState): boolean => status === 'Booted'; export const isBootingSimulatorStatus = ( - status: AppleSimulatorState + status: AppleSimulatorState, ): boolean => status === 'Booting'; export type AppleSimulatorInfo = { @@ -254,7 +254,7 @@ export const getSimulators = async (): Promise => { }; export const getSimulatorStatus = async ( - udid: string + udid: string, ): Promise => { const simulators = await getSimulators(); const simulator = simulators.find((s) => s.udid === udid); @@ -267,19 +267,19 @@ export const getSimulatorStatus = async ( }; export const getSimctlChildEnvironment = ( - options?: AppleAppLaunchOptions + options?: AppleAppLaunchOptions, ): Record => Object.fromEntries( Object.entries(options?.environment ?? {}).map(([key, value]) => [ `SIMCTL_CHILD_${key}`, value, - ]) + ]), ); export const startApp = async ( udid: string, bundleId: string, - options?: AppleAppLaunchOptions + options?: AppleAppLaunchOptions, ): Promise => { const environment = getSimctlChildEnvironment(options); const argumentsList = options?.arguments ?? []; @@ -291,7 +291,7 @@ export const startApp = async ( export const stopApp = async ( udid: string, - bundleId: string + bundleId: string, ): Promise => { await spawnAndForget('xcrun', ['simctl', 'terminate', udid, bundleId]); }; @@ -302,32 +302,52 @@ export const bootSimulator = async (udid: string): Promise => { export const waitForBoot = async ( udid: string, - signal: AbortSignal + signal: AbortSignal, ): Promise => { await spawn('xcrun', ['simctl', 'bootstatus', udid, '-b'], { signal, }); }; +export const diagnose = async ( + udid: string, + outputDir: string, +): Promise => { + await spawn( + 'xcrun', + [ + 'simctl', + 'diagnose', + `--udid=${udid}`, + '--no-archive', + `--output=${outputDir}`, + '-b', + ], + { + stdin: { string: '\n' }, + }, + ); +}; + export const shutdownSimulator = async (udid: string): Promise => { await spawnAndForget('xcrun', ['simctl', 'shutdown', udid]); }; export const installApp = async ( udid: string, - appPath: string + appPath: string, ): Promise => { await spawn('xcrun', ['simctl', 'install', udid, appPath]); }; export const getSimulatorId = async ( name: string, - systemVersion: string + systemVersion: string, ): Promise => { const simulators = await getSimulators(); const simulator = simulators.find( (s) => - s.name === name && s.runtime.endsWith(systemVersion.replaceAll('.', '-')) + s.name === name && s.runtime.endsWith(systemVersion.replaceAll('.', '-')), ); return simulator?.udid ?? null; @@ -335,7 +355,7 @@ export const getSimulatorId = async ( export const isAppRunning = async ( udid: string, - bundleId: string + bundleId: string, ): Promise => { try { const { stdout } = await spawn('xcrun', [ @@ -358,7 +378,7 @@ const HARNESS_MISSING_VALUE = '__RN_HARNESS_MISSING__'; const getDefaultsValue = async ( udid: string, bundleId: string, - key: string + key: string, ): Promise => { try { const { stdout } = await spawn('xcrun', [ @@ -384,7 +404,7 @@ const writeDefaultsValue = async ( udid: string, bundleId: string, key: string, - value: string + value: string, ): Promise => { await spawn('xcrun', [ 'simctl', @@ -401,7 +421,7 @@ const writeDefaultsValue = async ( const deleteDefaultsValue = async ( udid: string, bundleId: string, - key: string + key: string, ): Promise => { try { await spawn('xcrun', [ @@ -425,25 +445,25 @@ const deleteDefaultsValue = async ( export const applyHarnessJsLocationOverride = async ( udid: string, bundleId: string, - host: string + host: string, ): Promise => { const backupValue = await getDefaultsValue( udid, bundleId, - HARNESS_JS_LOCATION_BACKUP_KEY + HARNESS_JS_LOCATION_BACKUP_KEY, ); if (backupValue === null) { const existingValue = await getDefaultsValue( udid, bundleId, - 'RCT_jsLocation' + 'RCT_jsLocation', ); await writeDefaultsValue( udid, bundleId, HARNESS_JS_LOCATION_BACKUP_KEY, - existingValue ?? HARNESS_MISSING_VALUE + existingValue ?? HARNESS_MISSING_VALUE, ); } @@ -452,12 +472,12 @@ export const applyHarnessJsLocationOverride = async ( export const clearHarnessJsLocationOverride = async ( udid: string, - bundleId: string + bundleId: string, ): Promise => { const backupValue = await getDefaultsValue( udid, bundleId, - HARNESS_JS_LOCATION_BACKUP_KEY + HARNESS_JS_LOCATION_BACKUP_KEY, ); if (backupValue === null) { @@ -475,7 +495,7 @@ export const clearHarnessJsLocationOverride = async ( export const screenshot = async ( udid: string, - destination: string + destination: string, ): Promise => { await spawn('xcrun', ['simctl', 'io', udid, 'screenshot', destination]); return destination;