diff --git a/src/Microsoft.DotNet.XHarness.Apple/AppOperations/AppTester.cs b/src/Microsoft.DotNet.XHarness.Apple/AppOperations/AppTester.cs index d4b1f48ac..ffa3c1509 100644 --- a/src/Microsoft.DotNet.XHarness.Apple/AppOperations/AppTester.cs +++ b/src/Microsoft.DotNet.XHarness.Apple/AppOperations/AppTester.cs @@ -341,15 +341,28 @@ private async Task RunSimulatorTests( // On iOS 18 and later, transferring results over a TCP tunnel isn’t supported. // Instead, copy the results file from the device to the host machine. if (deviceListener.TestLog != null && - !await resultFileHandler.CopyResultsAsync( + resultFileHandler.IsVersionSupported(simulator.OSVersion, true)) + { + bool resultsCopied = await resultFileHandler.CopyResultsAsync( runMode, true, simulator.OSVersion, simulator.UDID, appInformation.BundleIdentifier, - deviceListener.TestLog.FullPath)) - { - throw new InvalidOperationException("Failed to copy test results from simulator to host."); + deviceListener.TestLog.FullPath); + + // If results weren't copied, it likely means the app crashed before tests could run + // Try to retrieve the crash report + if (!resultsCopied) + { + _mainLog.WriteLine("Test results file not found, app may have crashed before tests started."); + await resultFileHandler.CopyCrashReportAsync( + simulator.UDID, + simulator.Name, + appInformation, + _mainLog, + isSimulator: true); + } } } @@ -428,15 +441,28 @@ private async Task RunDeviceTests( // On iOS 18 and later, transferring results over a TCP tunnel isn’t supported. // Instead, copy the results file from the device to the host machine. if (deviceListener.TestLog != null && - !await resultFileHandler.CopyResultsAsync( + resultFileHandler.IsVersionSupported(device.OSVersion, false)) + { + bool resultsCopied = await resultFileHandler.CopyResultsAsync( runMode, false, device.OSVersion, device.UDID, appInformation.BundleIdentifier, - deviceListener.TestLog.FullPath)) - { - throw new InvalidOperationException("Failed to copy test results from device to host."); + deviceListener.TestLog.FullPath); + + // If results weren't copied, it likely means the app crashed before tests could run + // Try to retrieve the crash report + if (!resultsCopied) + { + _mainLog.WriteLine("Test results file not found, app may have crashed before tests started."); + await resultFileHandler.CopyCrashReportAsync( + device.UDID, + device.Name, + appInformation, + _mainLog, + isSimulator: false); + } } } diff --git a/src/Microsoft.DotNet.XHarness.iOS.Shared/IResultFileHandler.cs b/src/Microsoft.DotNet.XHarness.iOS.Shared/IResultFileHandler.cs index 468d80f21..d3479019a 100644 --- a/src/Microsoft.DotNet.XHarness.iOS.Shared/IResultFileHandler.cs +++ b/src/Microsoft.DotNet.XHarness.iOS.Shared/IResultFileHandler.cs @@ -25,4 +25,14 @@ Task CopyResultsAsync( string udid, string bundleIdentifier, string hostDestinationPath); + + /// + /// Copy the latest crash report from the device and dumps its content to the log. + /// + Task CopyCrashReportAsync( + string deviceUdid, + string? deviceName, + AppBundleInformation appInformation, + Common.Logging.ILog outputLog, + bool isSimulator); } diff --git a/src/Microsoft.DotNet.XHarness.iOS.Shared/ResultFileHandler.cs b/src/Microsoft.DotNet.XHarness.iOS.Shared/ResultFileHandler.cs index 421b4a692..1c7c70313 100644 --- a/src/Microsoft.DotNet.XHarness.iOS.Shared/ResultFileHandler.cs +++ b/src/Microsoft.DotNet.XHarness.iOS.Shared/ResultFileHandler.cs @@ -5,8 +5,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.DotNet.XHarness.Common.Execution; using Microsoft.DotNet.XHarness.Common.Logging; using Microsoft.DotNet.XHarness.iOS.Shared.Execution; @@ -104,4 +106,98 @@ await _processManager.ExecuteCommandAsync( return true; } + + public async Task CopyCrashReportAsync( + string deviceUdid, + string? deviceName, + AppBundleInformation appInformation, + ILog outputLog, + bool isSimulator) + { + _mainLog.WriteLine("Attempting to retrieve crash report from device..."); + + // List all crash reports on the device + string tempCrashListFile = Path.GetTempFileName(); + + MlaunchArguments listArgs = new MlaunchArguments(new ListCrashReportsArgument(tempCrashListFile)); + + if (!string.IsNullOrEmpty(deviceName)) + { + listArgs.Add(new DeviceNameArgument(deviceName)); + } + + ProcessExecutionResult listResult = await _processManager.ExecuteCommandAsync( + listArgs, + _mainLog, + TimeSpan.FromMinutes(1)); + + if (!listResult.Succeeded || !File.Exists(tempCrashListFile)) + { + _mainLog.WriteLine("Failed to list crash reports from device."); + return; + } + + List crashReports = File.ReadAllLines(tempCrashListFile) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + if (crashReports.Count == 0) + { + _mainLog.WriteLine("No crash reports found on device."); + return; + } + + // Filter for crash reports that might be related to our app + // .ips files typically follow the format: AppName-YYYY-MM-DD-HHMMSS.ips or similar + List appRelatedCrashes = crashReports + .Where(crash => crash.Contains(appInformation.AppName, StringComparison.OrdinalIgnoreCase) || + crash.EndsWith(".ips", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Use the last crash report (most recent) from the filtered list + string latestCrashReport = (appRelatedCrashes.Count > 0 ? appRelatedCrashes : crashReports).Last(); + + _mainLog.WriteLine($"Found crash report: {latestCrashReport}"); + + // Download the crash report + string? uploadRoot = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT"); + string crashReportContent; + + if (!string.IsNullOrEmpty(uploadRoot) && Directory.Exists(uploadRoot)) + { + string crashFileName = Path.GetFileName(latestCrashReport); + crashReportContent = Path.Combine(uploadRoot, crashFileName); + } + else + { + crashReportContent = Path.GetTempFileName(); + } + + MlaunchArguments downloadArgs = new MlaunchArguments( + new DownloadCrashReportArgument(latestCrashReport), + new DownloadCrashReportToArgument(crashReportContent)); + + if (!string.IsNullOrEmpty(deviceName)) + { + downloadArgs.Add(new DeviceNameArgument(deviceName)); + } + + ProcessExecutionResult downloadResult = await _processManager.ExecuteCommandAsync( + downloadArgs, + _mainLog, + TimeSpan.FromMinutes(1)); + + if (!downloadResult.Succeeded || !File.Exists(crashReportContent)) + { + _mainLog.WriteLine("Failed to download crash report from device."); + return; + } + + // Dump the crash report content to the log + _mainLog.WriteLine($"==================== Crash report ===================="); + _mainLog.WriteLine($"Crash report file: {crashReportContent}"); + string crashContent = await File.ReadAllTextAsync(crashReportContent); + _mainLog.WriteLine(crashContent); + _mainLog.WriteLine($"==================== End of Crash report ===================="); + } } diff --git a/tests/Microsoft.DotNet.XHarness.iOS.Shared.Tests/ResultFileHandlerTests.cs b/tests/Microsoft.DotNet.XHarness.iOS.Shared.Tests/ResultFileHandlerTests.cs index f917e8962..0670140a1 100644 --- a/tests/Microsoft.DotNet.XHarness.iOS.Shared.Tests/ResultFileHandlerTests.cs +++ b/tests/Microsoft.DotNet.XHarness.iOS.Shared.Tests/ResultFileHandlerTests.cs @@ -5,9 +5,13 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.DotNet.XHarness.Common.Execution; using Microsoft.DotNet.XHarness.Common.Logging; +using Microsoft.DotNet.XHarness.iOS.Shared; using Microsoft.DotNet.XHarness.iOS.Shared.Execution; using Moq; using Xunit; @@ -169,4 +173,90 @@ public async Task DeviceOsVersion18FileMissingReturnsFalse() Assert.False(result); log.Verify(l => l.WriteLine($"Failed to copy results file from device. Expected at: {_tempFile}"), Times.Once); } + + [Fact] + public async Task CopyCrashReportUsesHelixUploadRootWhenAvailable() + { + // Skip on Windows as mlaunch is not available + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + string originalUploadRoot = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT"); + string uploadRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(uploadRoot); + + try + { + Environment.SetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT", uploadRoot); + + Mock pm = new Mock(); + Mock log = new Mock(); + ResultFileHandler handler = CreateHandler(pm, log); + + string crashReportName = "MyApp-2025-11-25-223847.ips"; + string expectedDownloadPath = Path.Combine(uploadRoot, crashReportName); + string crashContent = "Dummy crash content"; + string actualDownloadPath = null; + + int callCount = 0; + + pm.Setup(m => m.ExecuteCommandAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((MlaunchArguments args, ILog _, TimeSpan _, Dictionary _, int _, CancellationToken? _) => + { + callCount++; + if (callCount == 1) + { + string listFilePath = GetArgumentValue(args, "list-crash-reports"); + File.WriteAllLines(listFilePath, new[] { crashReportName }); + } + else if (callCount == 2) + { + actualDownloadPath = GetArgumentValue(args, "download-crash-report-to"); + File.WriteAllText(actualDownloadPath, crashContent); + } + + return Task.FromResult(new ProcessExecutionResult { ExitCode = 0 }); + }); + + var appInfo = new AppBundleInformation("MyApp", "com.example.myapp", "/tmp", "/tmp", supports32b: false); + + await handler.CopyCrashReportAsync("device-udid", null, appInfo, log.Object, isSimulator: false); + + Assert.Equal(expectedDownloadPath, actualDownloadPath); + Assert.True(File.Exists(expectedDownloadPath)); + + log.Verify(l => l.WriteLine("Attempting to retrieve crash report from device..."), Times.Once); + log.Verify(l => l.WriteLine($"Found crash report: {crashReportName}"), Times.Once); + log.Verify(l => l.WriteLine("==================== Crash report ===================="), Times.Once); + log.Verify(l => l.WriteLine($"Crash report file: {expectedDownloadPath}"), Times.Once); + log.Verify(l => l.WriteLine(crashContent), Times.Once); + log.Verify(l => l.WriteLine("==================== End of Crash report ===================="), Times.Once); + } + finally + { + Environment.SetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT", originalUploadRoot); + + if (Directory.Exists(uploadRoot)) + { + Directory.Delete(uploadRoot, true); + } + } + } + + private static string GetArgumentValue(MlaunchArguments args, string argumentName) + { + string prefix = $"--{argumentName}="; + string argument = args.Select(a => a.AsCommandLineArgument()) + .First(a => a.StartsWith(prefix, StringComparison.Ordinal)); + + return argument.Substring(prefix.Length).Trim('"'); + } }