diff --git a/src/MauiSherpa.Cli/Program.cs b/src/MauiSherpa.Cli/Program.cs index c2c28111..64972671 100644 --- a/src/MauiSherpa.Cli/Program.cs +++ b/src/MauiSherpa.Cli/Program.cs @@ -17,5 +17,4 @@ CliOptions.Agent, }; -var config = new CommandLineConfiguration(root); -return await config.InvokeAsync(args); +return await root.Parse(args).InvokeAsync(); diff --git a/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs deleted file mode 100644 index 0e9b112f..00000000 --- a/src/MauiSherpa.Core/Handlers/Profiling/GetProfilingPrerequisitesHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Requests.Profiling; -using Shiny.Mediator; -using Shiny.Mediator.Caching; - -namespace MauiSherpa.Core.Handlers.Profiling; - -public partial class GetProfilingPrerequisitesHandler : IRequestHandler -{ - private readonly IProfilingPrerequisitesService _profilingPrerequisitesService; - - public GetProfilingPrerequisitesHandler(IProfilingPrerequisitesService profilingPrerequisitesService) - { - _profilingPrerequisitesService = profilingPrerequisitesService; - } - - [Cache(AbsoluteExpirationSeconds = 120)] - [OfflineAvailable] - public async Task Handle( - GetProfilingPrerequisitesRequest request, - IMediatorContext context, - CancellationToken ct) - { - return await _profilingPrerequisitesService.GetPrerequisitesAsync( - request.Platform, - request.CaptureKinds, - ct: ct); - } -} diff --git a/src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs b/src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs deleted file mode 100644 index b1a88216..00000000 --- a/src/MauiSherpa.Core/Handlers/Profiling/PlanProfilingCaptureHandler.cs +++ /dev/null @@ -1,27 +0,0 @@ -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Requests.Profiling; -using Shiny.Mediator; - -namespace MauiSherpa.Core.Handlers.Profiling; - -/// -/// Handler for building validation-friendly profiling capture command plans. -/// -public partial class PlanProfilingCaptureHandler : IRequestHandler -{ - private readonly IProfilingCaptureOrchestrationService _profilingCaptureOrchestrationService; - - public PlanProfilingCaptureHandler(IProfilingCaptureOrchestrationService profilingCaptureOrchestrationService) - { - _profilingCaptureOrchestrationService = profilingCaptureOrchestrationService; - } - - public async Task Handle( - PlanProfilingCaptureRequest request, - IMediatorContext context, - CancellationToken ct) - { - return await _profilingCaptureOrchestrationService.PlanCaptureAsync(request.Definition, request.Options, ct); - } -} diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 44641bb2..2db0b701 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -357,27 +357,27 @@ ProfilingSessionValidationResult ValidateSessionDefinition( ProfilingPlatformCapabilities capabilities); } -public interface IProfilingCapabilityProvider +public interface IMauiCliToolService { - ProfilingTargetPlatform Platform { get; } - Task GetCapabilitiesAsync(CancellationToken ct = default); + Task GetStatusAsync(CancellationToken ct = default); + Task GetUpdateInfoAsync(MauiCliToolStatus status, CancellationToken ct = default); + Task InstallAsync(string? version = null, CancellationToken ct = default); + Task UpdateAsync(string? version = null, CancellationToken ct = default); + Task> GetDevicesAsync(CancellationToken ct = default); } -public interface IProfilingPrerequisitesService +public interface IMauiProfilingCliService : IDisposable { - Task GetPrerequisitesAsync( - ProfilingTargetPlatform platform, - IReadOnlyList? captureKinds = null, - string? workingDirectory = null, - CancellationToken ct = default); -} + MauiProfileRunState State { get; } + event EventHandler? StateChanged; + event EventHandler? MessageReceived; -public interface IProfilingCaptureOrchestrationService -{ - Task PlanCaptureAsync( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions? options = null, + Task RunAsync( + MauiProfileRequest request, CancellationToken ct = default); + Task BeginRecordingAsync(CancellationToken ct = default); + Task StopRecordingAsync(CancellationToken ct = default); + void Cancel(); } public interface IProfilingArtifactLibraryService @@ -403,67 +403,6 @@ Task> AnalyzeArtifactsAsync( CancellationToken ct = default); } -/// -/// Executes a profiling capture plan as a coordinated multi-process pipeline. -/// Handles dependency ordering, parallel execution, long-running processes, -/// graceful stop, and artifact collection. -/// -public interface IProfilingSessionRunner : IDisposable -{ - /// Current pipeline state - ProfilingPipelineState State { get; } - - /// Status of each step in the pipeline - IReadOnlyList Steps { get; } - - /// Fired when pipeline state changes - event EventHandler? PipelineStateChanged; - - /// Fired when a step's state changes - event EventHandler? StepStateChanged; - - /// Fired when a step produces output - event EventHandler? StepOutputReceived; - - /// - /// Execute the full pipeline. Returns when all steps complete (or fail/cancel). - /// - Task RunAsync(ProfilingCapturePlan plan, CancellationToken ct = default); - - /// - /// Gracefully stop capture — sends SIGINT to ManualStop steps, waits for processes - /// to flush output files and exit before returning. - /// - Task StopCaptureAsync(); - - /// - /// Abort everything immediately — kills all running processes. - /// - void Cancel(); - - /// - /// Collect a GC dump on demand while the pipeline is running. - /// Returns the path to the .gcdump file, or null if collection failed. - /// - Task CollectGcDumpAsync(CancellationToken ct = default); - - /// - /// Whether a trace capture is currently active. - /// - bool IsTraceActive { get; } - - /// - /// Start an on-demand trace capture. Returns the step ID or null if it cannot start. - /// The trace runs until StopTraceAsync() is called. - /// - string? StartTraceAsync(); - - /// - /// Stop the currently running on-demand trace. - /// - Task StopTraceAsync(); -} - /// /// Parses and reports on GC dump (.gcdump) files by shelling out to dotnet-gcdump. /// @@ -508,6 +447,19 @@ public interface IProfilingSessionStorageService /// Delete a session and its folder. Task DeleteSessionAsync(string sessionId, CancellationToken ct = default); + /// Create and persist a completed session from a MAUI CLI profile result. + Task SaveMauiProfileSessionAsync( + string sessionId, + MauiProfileRequest request, + MauiProfileResult result, + string? cliVersion = null, + CancellationToken ct = default); + + /// Import a standalone supported profiling artifact as a managed session. + Task ImportArtifactAsync( + string artifactPath, + CancellationToken ct = default); + /// /// Get the directory path for a session. Creates the directory if it doesn't exist. /// diff --git a/src/MauiSherpa.Core/Models/Profiling/MauiProfilingCliModels.cs b/src/MauiSherpa.Core/Models/Profiling/MauiProfilingCliModels.cs new file mode 100644 index 00000000..50f53138 --- /dev/null +++ b/src/MauiSherpa.Core/Models/Profiling/MauiProfilingCliModels.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Models.Profiling; + +public enum MauiProfileMode +{ + Startup, + Interaction +} + +public enum MauiProfileOutputFormat +{ + NetTrace, + Speedscope, + Mibc +} + +public enum MauiProfileRunState +{ + Idle, + Starting, + AwaitingRecording, + Recording, + Finalizing, + Completed, + Failed, + Cancelled +} + +public enum MauiCliToolState +{ + Missing, + Available, + UpdateRequired +} + +public sealed record MauiCliToolStatus( + MauiCliToolState State, + string? ExecutablePath = null, + string? Version = null, + string? Message = null) +{ + public bool IsAvailable => State == MauiCliToolState.Available; +} + +public sealed record MauiCliToolUpdateInfo( + string? InstalledVersion = null, + string? LatestVersion = null, + bool UpdateAvailable = false, + string? Message = null); + +public sealed record MauiCliDevice +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("identifier")] + public required string Identifier { get; init; } + + [JsonPropertyName("emulator_id")] + public string? EmulatorId { get; init; } + + [JsonPropertyName("platforms")] + public required string[] Platforms { get; init; } + + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("version_name")] + public string? VersionName { get; init; } + + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("is_emulator")] + public bool IsEmulator { get; init; } + + [JsonPropertyName("is_running")] + public bool IsRunning { get; init; } + + [JsonPropertyName("connection_type")] + public string? ConnectionType { get; init; } + + public string Platform => Platforms.FirstOrDefault() ?? string.Empty; +} + +public sealed record MauiProfileRequest +{ + public required string ProjectPath { get; init; } + public required ProfilingTargetPlatform Platform { get; init; } + public required string DeviceId { get; init; } + public string? DeviceName { get; init; } + public bool IsEmulator { get; init; } + public required MauiProfileMode Mode { get; init; } + public MauiProfileOutputFormat Format { get; init; } = MauiProfileOutputFormat.Speedscope; + public required string OutputPath { get; init; } + public string Configuration { get; init; } = "Release"; + public TimeSpan? Duration { get; init; } + public string? TraceProfile { get; init; } + public bool NoBuild { get; init; } +} + +public sealed record MauiProfileResult +{ + public required string ProjectPath { get; init; } + public required string ProjectName { get; init; } + public required string Framework { get; init; } + public required string Platform { get; init; } + public required string DeviceId { get; init; } + public required string DeviceName { get; init; } + public required string Configuration { get; init; } + public required string Format { get; init; } + public required string OutputPath { get; init; } + public string? RawTracePath { get; init; } + public string? DsrouterKind { get; init; } + public string? DiagnosticAddress { get; init; } + public int? DiagnosticPort { get; init; } + public bool UsedStoppingEvent { get; init; } + public DateTimeOffset? StartedAtUtc { get; init; } + public DateTimeOffset? CompletedAtUtc { get; init; } + + /// + /// True when Sherpa reconstructed this result from the artifacts on disk because the + /// CLI captured a profile but failed to report it. + /// + public bool RecoveredFromDisk { get; init; } +} + +public sealed record MauiCliRemediation( + string Type, + string? Command, + IReadOnlyList ManualSteps); + +public abstract record MauiCliMessage; + +public sealed record MauiCliStatusMessage( + string Status, + string Message, + int? Percentage = null) : MauiCliMessage; + +public sealed record MauiCliErrorMessage( + string Code, + string Category, + string Severity, + string Message, + string? NativeError = null, + MauiCliRemediation? Remediation = null, + string? DocsUrl = null, + string? CorrelationId = null, + JsonElement? Context = null) : MauiCliMessage; + +public sealed record MauiProfileResultMessage(MauiProfileResult Result) : MauiCliMessage; + +public sealed record MauiCliDeviceListMessage( + IReadOnlyList Devices) : MauiCliMessage; + +public sealed record MauiCliVersionMessage( + string Version, + string? Runtime = null, + string? OperatingSystem = null) : MauiCliMessage; + +public sealed record MauiCliUnknownMessage(JsonElement Payload) : MauiCliMessage; + +public sealed record MauiProfileExecutionResult( + ProcessResult Process, + MauiProfileResult? Profile, + MauiCliErrorMessage? Error, + IReadOnlyList StatusMessages) +{ + // A profile is only present when a usable artifact exists, so it is a better success + // signal than the CLI exit code, which preview builds set even after a good capture. + public bool Success => Profile is not null && Error is null && !WasCancelled; + public bool WasCancelled => Process.WasCancelled || Process.ExitCode == 130; +} + +public sealed class MauiCliMessageEventArgs(MauiCliMessage message) : EventArgs +{ + public MauiCliMessage Message { get; } = message; +} + +public sealed class MauiProfileStateChangedEventArgs( + MauiProfileRunState oldState, + MauiProfileRunState newState) : EventArgs +{ + public MauiProfileRunState OldState { get; } = oldState; + public MauiProfileRunState NewState { get; } = newState; +} diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs deleted file mode 100644 index e0315748..00000000 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingCaptureOrchestrationModels.cs +++ /dev/null @@ -1,134 +0,0 @@ -using MauiSherpa.Core.Interfaces; - -namespace MauiSherpa.Core.Models.Profiling; - -public enum ProfilingCaptureLaunchMode -{ - Launch, - Attach -} - -public enum ProfilingCommandStepKind -{ - Prepare, - Build, - Launch, - DiscoverProcess, - Connect, - Capture, - CollectArtifacts, - Cleanup -} - -public enum ProfilingDiagnosticListenMode -{ - Connect, - Listen -} - -public enum ProfilingDsRouterMode -{ - None, - ServerServer, - ServerClient -} - -public enum ProfilingStopTrigger -{ - None, // Step exits on its own - ManualStop, // User must explicitly stop it - OnPipelineStop // Stopped when the pipeline stops -} - -public record ProfilingCapturePlanOptions( - string? ProjectPath = null, - string Configuration = "Release", - string? TargetFramework = null, - string? WorkingDirectory = null, - string? OutputDirectory = null, - ProfilingCaptureLaunchMode LaunchMode = ProfilingCaptureLaunchMode.Launch, - int DiagnosticPort = 9000, - bool SuspendAtStartup = false, - int? ProcessId = null, - IReadOnlyDictionary? AdditionalBuildProperties = null -); - -public record ProfilingPlanValidation( - IReadOnlyList Errors, - IReadOnlyList Warnings -) -{ - public bool IsValid => Errors.Count == 0; -} - -public record ProfilingDiagnosticConfiguration( - string Address, - int Port, - ProfilingDiagnosticListenMode ListenMode, - bool SuspendOnStartup, - bool RequiresDsRouter, - ProfilingDsRouterMode DsRouterMode, - string? DsRouterPortForwardPlatform, - string IpcAddress, - string TcpEndpoint -); - -public record ProfilingRuntimeBinding( - string Token, - string Description, - bool IsRequired = true, - string? ExampleValue = null -); - -public record ProfilingCommandStep( - string Id, - ProfilingCommandStepKind Kind, - string DisplayName, - string Description, - string Command, - IReadOnlyList Arguments, - string? WorkingDirectory = null, - IReadOnlyDictionary? Environment = null, - IReadOnlyList? DependsOn = null, - IReadOnlyList? RequiredRuntimeBindings = null, - IReadOnlyDictionary? Metadata = null, - bool IsOptional = false, - bool IsLongRunning = false, - bool RequiresManualStop = false, - bool CanRunParallel = false, - ProfilingStopTrigger StopTrigger = ProfilingStopTrigger.None, - string? ReadyOutputPattern = null) -{ - public string CommandLine => Arguments.Count > 0 - ? $"{Command} {string.Join(" ", Arguments)}" - : Command; - - public ProcessRequest ToProcessRequest(string? title = null, string? description = null) => new( - Command, - Arguments.ToArray(), - WorkingDirectory, - Environment: Environment?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase), - Title: title ?? DisplayName, - Description: description ?? Description); -} - -public record ProfilingCapturePlan( - ProfilingSessionDefinition Session, - ProfilingPlatformCapabilities Capabilities, - ProfilingCapturePlanOptions Options, - string HostPlatform, - string TargetFramework, - string OutputDirectory, - string? WorkingDirectory, - bool IsTargetCurrentlyAvailable, - ProfilingDiagnosticConfiguration? Diagnostics, - ProfilingPrerequisiteReport? Prerequisites, - ProfilingPlanValidation Validation, - IReadOnlyList RuntimeBindings, - IReadOnlyList Commands, - IReadOnlyList ExpectedArtifacts, - IReadOnlyDictionary Metadata) -{ - public bool RequiresRuntimeInputs => RuntimeBindings.Any(binding => binding.IsRequired); - public bool CanExecute => Validation.IsValid && !RequiresRuntimeInputs; -} diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs index bb1d7bec..6a9555e7 100644 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingCatalogModels.cs @@ -6,7 +6,8 @@ public enum ProfilingTargetPlatform iOS, MacCatalyst, MacOS, - Windows + Windows, + Unknown } public enum ProfilingTargetKind @@ -14,7 +15,8 @@ public enum ProfilingTargetKind PhysicalDevice, Emulator, Simulator, - Desktop + Desktop, + Unknown } public enum ProfilingCaptureKind @@ -26,7 +28,8 @@ public enum ProfilingCaptureKind Rendering, Energy, SystemTrace, - Logs + Logs, + Interaction } public enum ProfilingScenarioKind @@ -41,6 +44,7 @@ public enum ProfilingScenarioKind public enum ProfilingArtifactKind { Trace, + Mibc, Metrics, Screenshot, Logs, diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs deleted file mode 100644 index 824b4640..00000000 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingPipelineModels.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace MauiSherpa.Core.Models.Profiling; - -public enum ProfilingPipelineState -{ - NotStarted, - Running, - WaitingForStop, - Completing, - Completed, - Failed, - Cancelled -} - -public enum ProfilingStepState -{ - Pending, - WaitingForDependencies, - Running, - Completed, - Failed, - Stopped, - Skipped, - Cancelled -} - -public class ProfilingStepStatus -{ - public required string StepId { get; init; } - public required string DisplayName { get; init; } - public required ProfilingCommandStepKind Kind { get; init; } - public ProfilingStepState State { get; set; } = ProfilingStepState.Pending; - public List OutputLines { get; } = new(); - public TimeSpan? Duration { get; set; } - public int? ExitCode { get; set; } - public int? ProcessId { get; set; } - public string? ErrorMessage { get; set; } - public DateTime? StartedAt { get; set; } - public DateTime? CompletedAt { get; set; } - public bool IsLongRunning { get; init; } - public bool CanRunParallel { get; init; } - public ProfilingStopTrigger StopTrigger { get; init; } - - /// - /// True when a long-running step has established its connection and is - /// actively capturing. Used to gate dependent non-long-running steps - /// (e.g. gcdump waits for trace to connect before running). - /// - public bool IsReady { get; set; } -} - -public record ProfilingStepOutputLine(string Text, bool IsError, DateTime Timestamp); - -public record ProfilingPipelineResult( - bool Success, - TimeSpan TotalDuration, - ProfilingPipelineState FinalState, - IReadOnlyList StepResults, - IReadOnlyList ArtifactPaths, - IReadOnlyList MissingArtifacts); - -public class ProfilingPipelineStateChangedEventArgs : EventArgs -{ - public ProfilingPipelineState OldState { get; init; } - public ProfilingPipelineState NewState { get; init; } -} - -public class ProfilingStepStateChangedEventArgs : EventArgs -{ - public required string StepId { get; init; } - public ProfilingStepState OldState { get; init; } - public ProfilingStepState NewState { get; init; } -} - -public class ProfilingStepOutputEventArgs : EventArgs -{ - public required string StepId { get; init; } - public required string Text { get; init; } - public bool IsError { get; init; } -} diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs deleted file mode 100644 index 90c6b456..00000000 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingPrerequisitesModels.cs +++ /dev/null @@ -1,55 +0,0 @@ -using MauiSherpa.Core.Interfaces; - -namespace MauiSherpa.Core.Models.Profiling; - -public enum ProfilingPrerequisiteKind -{ - HostPlatform, - DotNetSdk, - DotNetTool, - AndroidToolchain, - AppleToolchain, - WindowsToolchain, - Other -} - -public record ProfilingPrerequisiteContext( - ProfilingTargetPlatform Platform, - IReadOnlyList CaptureKinds, - string? WorkingDirectory, - string? DotNetExecutablePath, - DoctorContext DoctorContext -); - -public record ProfilingPrerequisiteStatus( - string Name, - ProfilingPrerequisiteKind Kind, - DependencyStatusType Status, - bool IsRequired, - string? RequiredVersion, - string? RecommendedVersion, - string? InstalledVersion, - string? Message, - string? DiscoveredBy = null, - string? ExecutablePath = null, - bool IsFixable = false, - string? FixAction = null, - string? SuggestedCommand = null -); - -public record ProfilingPrerequisiteReport( - ProfilingPrerequisiteContext Context, - IReadOnlyList Checks, - DateTimeOffset Timestamp -) -{ - public bool IsReady => Checks - .Where(check => check.IsRequired) - .All(check => check.Status != DependencyStatusType.Error); - - public bool HasErrors => Checks.Any(check => check.Status == DependencyStatusType.Error); - public bool HasWarnings => Checks.Any(check => check.Status == DependencyStatusType.Warning); - public int OkCount => Checks.Count(check => check.Status == DependencyStatusType.Ok || check.Status == DependencyStatusType.Info); - public int WarningCount => Checks.Count(check => check.Status == DependencyStatusType.Warning); - public int ErrorCount => Checks.Count(check => check.Status == DependencyStatusType.Error); -} diff --git a/src/MauiSherpa.Core/Models/Profiling/ProfilingSessionManifestModels.cs b/src/MauiSherpa.Core/Models/Profiling/ProfilingSessionManifestModels.cs index 2aa9397e..fc7aa228 100644 --- a/src/MauiSherpa.Core/Models/Profiling/ProfilingSessionManifestModels.cs +++ b/src/MauiSherpa.Core/Models/Profiling/ProfilingSessionManifestModels.cs @@ -2,6 +2,13 @@ namespace MauiSherpa.Core.Models.Profiling; +// Retained for deserializing sessions captured by Sherpa's legacy pipeline. +public enum ProfilingCaptureLaunchMode +{ + Launch, + Attach +} + /// /// Status of a profiling session. /// @@ -19,6 +26,11 @@ public enum ProfilingSessionStatus /// public record ProfilingSessionManifest { + /// + /// Manifest schema. Legacy manifests without this property deserialize as version 1. + /// + public int SchemaVersion { get; init; } = 1; + public required string Id { get; init; } public required string Name { get; init; } public ProfilingSessionStatus Status { get; set; } = ProfilingSessionStatus.InProgress; @@ -40,6 +52,9 @@ public record ProfilingSessionManifest /// Pipeline execution summary (populated after capture). public ProfilingSessionPipelineSummary? Pipeline { get; set; } + /// MAUI CLI metadata for schema version 2 sessions. + public MauiProfileSessionDetails? MauiProfile { get; init; } + /// Artifact files produced by this session. public List Artifacts { get; set; } = new(); @@ -113,3 +128,15 @@ public record ProfilingSessionArtifact public long? SizeBytes { get; init; } public string? DisplayName { get; init; } } + +public record MauiProfileSessionDetails +{ + public required MauiProfileMode Mode { get; init; } + public required MauiProfileOutputFormat Format { get; init; } + public string? CliVersion { get; init; } + public string? Framework { get; init; } + public string? RawTraceFileName { get; init; } + public bool UsedStoppingEvent { get; init; } + public DateTimeOffset? StartedAtUtc { get; init; } + public DateTimeOffset? CompletedAtUtc { get; init; } +} diff --git a/src/MauiSherpa.Core/Properties/AssemblyInfo.cs b/src/MauiSherpa.Core/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..8be2ce39 --- /dev/null +++ b/src/MauiSherpa.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MauiSherpa.Core.Tests")] diff --git a/src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs deleted file mode 100644 index 30cbf110..00000000 --- a/src/MauiSherpa.Core/Requests/Profiling/GetProfilingPrerequisitesRequest.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using Shiny.Mediator; - -namespace MauiSherpa.Core.Requests.Profiling; - -public record GetProfilingPrerequisitesRequest( - ProfilingTargetPlatform Platform, - IReadOnlyList? CaptureKinds = null) : IRequest, IContractKey -{ - public string GetKey() - { - var normalizedCaptureKinds = CaptureKinds is { Count: > 0 } - ? string.Join(",", CaptureKinds.Distinct().OrderBy(kind => kind)) - : "default"; - - return $"profiling:prerequisites:{Platform}:{normalizedCaptureKinds}"; - } -} diff --git a/src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs b/src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs deleted file mode 100644 index d3a6207b..00000000 --- a/src/MauiSherpa.Core/Requests/Profiling/PlanProfilingCaptureRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using MauiSherpa.Core.Models.Profiling; -using Shiny.Mediator; - -namespace MauiSherpa.Core.Requests.Profiling; - -public record PlanProfilingCaptureRequest( - ProfilingSessionDefinition Definition, - ProfilingCapturePlanOptions? Options = null) : IRequest; diff --git a/src/MauiSherpa.Core/Services/CopilotToolsService.cs b/src/MauiSherpa.Core/Services/CopilotToolsService.cs index f5669f0d..53350741 100644 --- a/src/MauiSherpa.Core/Services/CopilotToolsService.cs +++ b/src/MauiSherpa.Core/Services/CopilotToolsService.cs @@ -159,9 +159,9 @@ private void InitializeTools() // Profiling Tools AddTool(AIFunctionFactory.Create(ListProfilingTargetsAsync, "list_profiling_targets", - "List currently available local profiling targets discovered via MAUI DevFlow."), isReadOnly: true); + "List running MAUI apps available for DevFlow live profiling. This is separate from maui profile capture."), isReadOnly: true); AddTool(AIFunctionFactory.Create(GetProfilingCatalogAsync, "get_profiling_catalog", - "Get the supported profiling scenarios and platform capabilities available in Maui Sherpa. Optionally filter to a single platform."), isReadOnly: true); + "Get MAUI CLI-backed capture modes, formats, and supported Android or iOS simulator targets. Optionally filter to one platform."), isReadOnly: true); AddTool(AIFunctionFactory.Create(ListProfilingArtifactsAsync, "list_profiling_artifacts", "List profiling artifacts stored in Sherpa's artifact library. Optionally filter by session or artifact kind."), isReadOnly: true); AddTool(AIFunctionFactory.Create(GetProfilingSnapshotAsync, "get_profiling_snapshot", @@ -1558,30 +1558,44 @@ private async Task ListProfilingTargetsAsync() return JsonSerializer.Serialize(targets, new JsonSerializerOptions { WriteIndented = true }); } - [Description("Get supported profiling scenarios and platform capabilities")] + [Description("Get MAUI CLI-backed profile capture capabilities")] private async Task GetProfilingCatalogAsync( - [Description("Optional platform name to filter to: Android, iOS, MacCatalyst, MacOS, or Windows")] string? platform = null) + [Description("Optional platform name to filter to: Android or iOS")] string? platform = null) { _logger.LogDebug($"Tool: get_profiling_catalog called with platform '{platform ?? ""}'"); var catalog = await _profilingCatalogService.GetCatalogAsync(); - if (string.IsNullOrWhiteSpace(platform)) + var platformCapabilities = catalog.Platforms.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(platform)) { - return JsonSerializer.Serialize(catalog, new JsonSerializerOptions { WriteIndented = true }); - } + platformCapabilities = platformCapabilities.Where(capabilities => + capabilities.Platform.ToString().Equals(platform, StringComparison.OrdinalIgnoreCase)); - if (!Enum.TryParse(platform, ignoreCase: true, out var parsedPlatform)) - { - return $"Unknown platform '{platform}'. Valid values: {string.Join(", ", Enum.GetNames())}."; + if (!platformCapabilities.Any()) + return $"Unknown or unsupported platform '{platform}'. Valid values: Android, iOS."; } - var capabilities = await _profilingCatalogService.GetCapabilitiesAsync(parsedPlatform); var result = new { - Platform = capabilities, - Scenarios = catalog.Scenarios - .Where(scenario => capabilities.SupportedScenarios.Contains(scenario.Kind)) - .ToArray() + CaptureEngine = "maui profile", + ToolPackage = "Microsoft.Maui.Cli", + Formats = new[] { "nettrace", "speedscope", "mibc" }, + Platforms = platformCapabilities.Select(capabilities => new + { + Name = capabilities.Platform.ToString(), + capabilities.DisplayName, + TargetKinds = capabilities.SupportedTargetKinds.Select(kind => kind.ToString()).ToArray(), + Modes = catalog.Scenarios + .Where(scenario => capabilities.SupportedScenarios.Contains(scenario.Kind)) + .Select(scenario => new + { + Name = scenario.DisplayName, + scenario.Description + }) + .ToArray(), + capabilities.SupportsSymbolication, + capabilities.Notes + }).ToArray() }; return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); diff --git a/src/MauiSherpa.Core/Services/MauiCliExecutableResolver.cs b/src/MauiSherpa.Core/Services/MauiCliExecutableResolver.cs new file mode 100644 index 00000000..5122cf19 --- /dev/null +++ b/src/MauiSherpa.Core/Services/MauiCliExecutableResolver.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; + +namespace MauiSherpa.Core.Services; + +public static class MauiCliExecutableResolver +{ + public static string? Resolve( + string? userProfile = null, + string? pathEnvironment = null, + bool? isWindows = null) + { + var windows = isWindows ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var executableName = windows ? "maui.exe" : "maui"; + var profile = userProfile ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (!string.IsNullOrWhiteSpace(profile)) + { + var globalToolPath = Path.Combine(profile, ".dotnet", "tools", executableName); + if (File.Exists(globalToolPath)) + return globalToolPath; + } + + var path = pathEnvironment ?? Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + return null; + + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory.Trim(), executableName); + if (File.Exists(candidate)) + return candidate; + } + + return null; + } +} diff --git a/src/MauiSherpa.Core/Services/MauiCliJsonStreamParser.cs b/src/MauiSherpa.Core/Services/MauiCliJsonStreamParser.cs new file mode 100644 index 00000000..fceec0ff --- /dev/null +++ b/src/MauiSherpa.Core/Services/MauiCliJsonStreamParser.cs @@ -0,0 +1,306 @@ +using System.Text; +using System.Text.Json; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public sealed class MauiCliJsonStreamParser +{ + private readonly StringBuilder _buffer = new(); + private bool _started; + private bool _inString; + private bool _escaped; + private int _depth; + + public IReadOnlyList Append(string fragment) + { + if (string.IsNullOrEmpty(fragment)) + return []; + + var messages = new List(); + + foreach (var character in fragment) + { + if (!_started) + { + if (character is not ('{' or '[')) + continue; + + _started = true; + _depth = 1; + _buffer.Append(character); + continue; + } + + _buffer.Append(character); + + if (_inString) + { + if (_escaped) + { + _escaped = false; + continue; + } + + if (character == '\\') + { + _escaped = true; + continue; + } + + if (character == '"') + _inString = false; + + continue; + } + + if (character == '"') + { + _inString = true; + continue; + } + + if (character is '{' or '[') + _depth++; + else if (character is '}' or ']') + _depth--; + + if (_depth != 0) + continue; + + var json = _buffer.ToString(); + ResetFrame(); + + try + { + using var document = JsonDocument.Parse(json); + messages.Add(ParseMessage(document.RootElement)); + } + catch (JsonException) + { + // Human output can contain braces. Ignore invalid frames and resume scanning. + } + } + + return messages; + } + + private void ResetFrame() + { + _buffer.Clear(); + _started = false; + _inString = false; + _escaped = false; + _depth = 0; + } + + private static MauiCliMessage ParseMessage(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Array) + return new MauiCliDeviceListMessage(ParseDevices(root)); + + if (root.ValueKind != JsonValueKind.Object) + return new MauiCliUnknownMessage(root.Clone()); + + if (TryGetString(root, "code", out var code)) + return ParseError(root, code); + + if (TryGetString(root, "status", out var status) && + TryGetString(root, "message", out var statusMessage)) + { + return new MauiCliStatusMessage( + status, + statusMessage, + TryGetInt32(root, "percentage")); + } + + if (TryGetString(root, "output_path", out _)) + return new MauiProfileResultMessage(ParseProfileResult(root)); + + if (TryGetString(root, "version", out var version)) + { + return new MauiCliVersionMessage( + version, + GetString(root, "runtime"), + GetString(root, "os")); + } + + return new MauiCliUnknownMessage(root.Clone()); + } + + private static MauiCliErrorMessage ParseError(JsonElement root, string code) + { + MauiCliRemediation? remediation = null; + if (TryGetProperty(root, "remediation", out var remediationElement) && + remediationElement.ValueKind == JsonValueKind.Object) + { + remediation = new MauiCliRemediation( + GetString(remediationElement, "type") ?? "unknown", + GetString(remediationElement, "command"), + GetStringArray(remediationElement, "manual_steps")); + } + + JsonElement? context = null; + if (TryGetProperty(root, "context", out var contextElement)) + context = contextElement.Clone(); + + return new MauiCliErrorMessage( + code, + GetString(root, "category") ?? "tool", + GetString(root, "severity") ?? "error", + GetString(root, "message") ?? "The MAUI CLI command failed.", + GetString(root, "native_error"), + remediation, + GetString(root, "docs_url"), + GetString(root, "correlation_id"), + context); + } + + private static MauiProfileResult ParseProfileResult(JsonElement root) + { + return new MauiProfileResult + { + ProjectPath = GetRequiredString(root, "project_path"), + ProjectName = GetRequiredString(root, "project_name"), + Framework = GetRequiredString(root, "framework"), + Platform = GetRequiredString(root, "platform"), + DeviceId = GetRequiredString(root, "device_id"), + DeviceName = GetRequiredString(root, "device_name"), + Configuration = GetRequiredString(root, "configuration"), + Format = GetRequiredString(root, "format"), + OutputPath = GetRequiredString(root, "output_path"), + RawTracePath = GetString(root, "raw_trace_path"), + DsrouterKind = GetString(root, "dsrouter_kind"), + DiagnosticAddress = GetString(root, "diagnostic_address"), + DiagnosticPort = TryGetInt32(root, "diagnostic_port"), + UsedStoppingEvent = TryGetBoolean(root, "used_stopping_event") ?? false, + StartedAtUtc = TryGetDateTimeOffset(root, "started_at_utc"), + CompletedAtUtc = TryGetDateTimeOffset(root, "completed_at_utc") + }; + } + + private static IReadOnlyList ParseDevices(JsonElement root) + { + var devices = new List(); + foreach (var item in root.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + continue; + + var name = GetString(item, "name"); + var identifier = GetString(item, "identifier"); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(identifier)) + continue; + + devices.Add(new MauiCliDevice + { + Name = name, + Identifier = identifier, + EmulatorId = GetString(item, "emulator_id"), + Platforms = GetStringArray(item, "platforms").ToArray(), + Version = GetString(item, "version"), + VersionName = GetString(item, "version_name"), + Model = GetString(item, "model"), + IsEmulator = TryGetBoolean(item, "is_emulator") ?? false, + IsRunning = TryGetBoolean(item, "is_running") ?? false, + ConnectionType = GetString(item, "connection_type") + }); + } + + return devices; + } + + private static string GetRequiredString(JsonElement element, string propertyName) + { + return GetString(element, propertyName) + ?? throw new JsonException($"Required MAUI CLI property '{propertyName}' was missing."); + } + + private static string? GetString(JsonElement element, string propertyName) + { + return TryGetString(element, propertyName, out var value) ? value : null; + } + + private static bool TryGetString(JsonElement element, string propertyName, out string value) + { + value = string.Empty; + if (!TryGetProperty(element, propertyName, out var property) || + property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString() ?? string.Empty; + return true; + } + + private static int? TryGetInt32(JsonElement element, string propertyName) + { + return TryGetProperty(element, propertyName, out var property) && + property.TryGetInt32(out var value) + ? value + : null; + } + + private static bool? TryGetBoolean(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var property)) + return null; + + return property.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + } + + private static DateTimeOffset? TryGetDateTimeOffset(JsonElement element, string propertyName) + { + return TryGetString(element, propertyName, out var value) && + DateTimeOffset.TryParse(value, out var parsed) + ? parsed + : null; + } + + private static IReadOnlyList GetStringArray(JsonElement element, string propertyName) + { + if (!TryGetProperty(element, propertyName, out var property) || + property.ValueKind != JsonValueKind.Array) + { + return []; + } + + return property + .EnumerateArray() + .Where(x => x.ValueKind == JsonValueKind.String) + .Select(x => x.GetString()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToArray(); + } + + private static bool TryGetProperty( + JsonElement element, + string propertyName, + out JsonElement property) + { + var normalizedName = Normalize(propertyName); + foreach (var candidate in element.EnumerateObject()) + { + if (Normalize(candidate.Name) == normalizedName) + { + property = candidate.Value; + return true; + } + } + + property = default; + return false; + } + + private static string Normalize(string value) + { + return string.Concat(value.Where(char.IsLetterOrDigit)).ToUpperInvariant(); + } +} diff --git a/src/MauiSherpa.Core/Services/MauiCliToolService.cs b/src/MauiSherpa.Core/Services/MauiCliToolService.cs new file mode 100644 index 00000000..44f10da4 --- /dev/null +++ b/src/MauiSherpa.Core/Services/MauiCliToolService.cs @@ -0,0 +1,255 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Workloads.NuGet; +using NuGet.Versioning; + +namespace MauiSherpa.Core.Services; + +public sealed class MauiCliToolService : IMauiCliToolService +{ + private const string PackageId = "Microsoft.Maui.Cli"; + private static readonly TimeSpan UpdateCheckTimeout = TimeSpan.FromSeconds(20); + + private readonly IProcessExecutionService _process; + private readonly ILoggingService _logger; + private readonly Func _resolveExecutable; + private readonly Func _nugetClientFactory; + private INuGetClient? _nugetClient; + + public MauiCliToolService( + IProcessExecutionService process, + ILoggingService logger) + : this(process, logger, () => MauiCliExecutableResolver.Resolve()) + { + } + + public MauiCliToolService( + IProcessExecutionService process, + ILoggingService logger, + Func resolveExecutable, + Func? nugetClientFactory = null) + { + _process = process; + _logger = logger; + _resolveExecutable = resolveExecutable; + _nugetClientFactory = nugetClientFactory ?? (() => new NuGetClient()); + } + + public async Task GetStatusAsync(CancellationToken ct = default) + { + var executablePath = _resolveExecutable(); + if (string.IsNullOrWhiteSpace(executablePath)) + { + return new MauiCliToolStatus( + MauiCliToolState.Missing, + Message: "The Microsoft MAUI CLI global tool is not installed."); + } + + var versionResult = await ExecuteAsync( + executablePath, + ["version", "--json", "--ci"], + "Checking MAUI CLI", + ct); + + var version = NormalizeVersion(ParseMessages(versionResult.Output) + .OfType() + .LastOrDefault() + ?.Version); + + if (!versionResult.Success) + { + return new MauiCliToolStatus( + MauiCliToolState.UpdateRequired, + executablePath, + version, + "The installed MAUI CLI could not report its version."); + } + + var startupHelp = await ExecuteAsync( + executablePath, + ["profile", "startup", "--help"], + "Checking startup profiling", + ct); + var manualHelp = await ExecuteAsync( + executablePath, + ["profile", "manual", "--help"], + "Checking interaction profiling", + ct); + + if (!startupHelp.Success || !manualHelp.Success) + { + return new MauiCliToolStatus( + MauiCliToolState.UpdateRequired, + executablePath, + version, + "Update Microsoft.Maui.Cli to a version that supports startup and manual profiling."); + } + + return new MauiCliToolStatus( + MauiCliToolState.Available, + executablePath, + version); + } + + public async Task GetUpdateInfoAsync( + MauiCliToolStatus status, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(status); + + var installedText = status.Version; + if (status.State == MauiCliToolState.Missing) + return new MauiCliToolUpdateInfo(Message: "The MAUI CLI global tool is not installed."); + + NuGetVersion? installed = null; + if (!string.IsNullOrWhiteSpace(installedText)) + NuGetVersion.TryParse(installedText, out installed); + + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(UpdateCheckTimeout); + + _nugetClient ??= _nugetClientFactory(); + var versions = await _nugetClient.GetPackageVersionsAsync( + PackageId, + includePrerelease: installed?.IsPrerelease ?? true, + timeout.Token); + + var latest = versions.Count == 0 ? null : versions.Max(); + if (latest is null) + return new MauiCliToolUpdateInfo(installedText, Message: "No published MAUI CLI versions were found."); + + var latestText = latest.ToNormalizedString(); + if (installed is null) + { + return new MauiCliToolUpdateInfo( + installedText, + latestText, + Message: "Sherpa could not read the installed MAUI CLI version."); + } + + return new MauiCliToolUpdateInfo( + installed.ToNormalizedString(), + latestText, + latest > installed); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new MauiCliToolUpdateInfo(installedText, Message: "Timed out while checking NuGet for MAUI CLI updates."); + } + catch (Exception ex) + { + _logger.LogWarning($"Unable to check for MAUI CLI updates: {ex.Message}"); + return new MauiCliToolUpdateInfo(installedText, Message: $"Unable to check NuGet for updates: {ex.Message}"); + } + } + + public Task InstallAsync(string? version = null, CancellationToken ct = default) + { + return ExecuteAsync( + "dotnet", + BuildToolArguments("install", version), + "Installing MAUI CLI", + ct); + } + + public Task UpdateAsync(string? version = null, CancellationToken ct = default) + { + return ExecuteAsync( + "dotnet", + BuildToolArguments("update", version), + "Updating MAUI CLI", + ct); + } + + // Microsoft.Maui.Cli currently ships prerelease-only, so `dotnet tool install/update` + // cannot resolve it without either an explicit version or --prerelease. + private static string? NormalizeVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + var trimmed = version.Trim(); + return NuGetVersion.TryParse(trimmed, out var parsed) + ? parsed.ToNormalizedString() + : trimmed; + } + + private static string[] BuildToolArguments(string verb, string? version) + { + string[] arguments = ["tool", verb, "--global", PackageId]; + return string.IsNullOrWhiteSpace(version) + ? [.. arguments, "--prerelease"] + : [.. arguments, "--version", version]; + } + + public async Task> GetDevicesAsync(CancellationToken ct = default) + { + var status = await GetStatusAsync(ct); + if (!status.IsAvailable || string.IsNullOrWhiteSpace(status.ExecutablePath)) + throw new InvalidOperationException(status.Message ?? "The MAUI CLI is not ready."); + + var result = await ExecuteAsync( + status.ExecutablePath, + ["device", "list", "--platform", "all", "--json", "--ci"], + "Listing MAUI devices", + ct); + + var messages = ParseMessages(result.Output); + var error = messages.OfType().LastOrDefault(); + if (!result.Success) + { + var message = error?.Message; + if (string.IsNullOrWhiteSpace(message)) + message = string.IsNullOrWhiteSpace(result.Error) ? "Unable to list MAUI devices." : result.Error; + throw new InvalidOperationException(message); + } + + var devices = messages + .OfType() + .LastOrDefault() + ?.Devices; + + if (devices is null) + throw new InvalidOperationException("The MAUI CLI returned an unexpected device response."); + + return devices + .Where(IsSupportedRunningTarget) + .OrderBy(x => x.Platform) + .ThenBy(x => x.Name) + .ToArray(); + } + + private Task ExecuteAsync( + string command, + string[] arguments, + string title, + CancellationToken ct) + { + _logger.LogDebug($"{title}: {command} {string.Join(' ', arguments)}"); + return _process.ExecuteAsync( + new ProcessRequest( + command, + arguments, + Title: title), + ct); + } + + private static IReadOnlyList ParseMessages(string output) + { + return new MauiCliJsonStreamParser().Append(output); + } + + private static bool IsSupportedRunningTarget(MauiCliDevice device) + { + if (!device.IsRunning) + return false; + + if (device.Platforms.Any(x => string.Equals(x, "android", StringComparison.OrdinalIgnoreCase))) + return true; + + return device.IsEmulator && + device.Platforms.Any(x => string.Equals(x, "ios", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/MauiSherpa.Core/Services/MauiProfileArtifactRecovery.cs b/src/MauiSherpa.Core/Services/MauiProfileArtifactRecovery.cs new file mode 100644 index 00000000..238830f6 --- /dev/null +++ b/src/MauiSherpa.Core/Services/MauiProfileArtifactRecovery.cs @@ -0,0 +1,222 @@ +using System.Text.RegularExpressions; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +/// +/// Rebuilds a from the files the MAUI CLI wrote to the +/// requested --output location when the CLI captured a profile but failed to +/// report it. +/// +/// +/// Sherpa always supplies an explicit --output path, so the files on disk are a +/// stronger success signal than the CLI's own reporting. Preview builds of +/// Microsoft.Maui.Cli crash while serializing their profile result because +/// MauiCliJsonContext does not include MauiProfileResult; the trace is +/// already written by that point, so the capture must not be discarded. +/// +public static partial class MauiProfileArtifactRecovery +{ + private const string SpeedscopeSuffix = ".speedscope.json"; + private const string NetTraceSuffix = ".nettrace"; + private const string MibcSuffix = ".mibc"; + + /// + /// Artifacts written before the run started belong to an earlier capture. The window is + /// generous because Sherpa creates a fresh output directory for every run and file + /// timestamp granularity varies between file systems. + /// + private static readonly TimeSpan WriteTimeTolerance = TimeSpan.FromMinutes(1); + + /// + /// Detects the known Microsoft.Maui.Cli defect where the CLI throws while + /// serializing a command result because its source-generated JSON context is missing + /// the result type. + /// + public static bool IsResultSerializationFailure(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + return false; + + return output.Contains("JsonTypeInfo metadata for type", StringComparison.OrdinalIgnoreCase) && + output.Contains("JsonContext", StringComparison.OrdinalIgnoreCase); + } + + public static MauiProfileResult? TryRecover( + MauiProfileRequest request, + DateTimeOffset startedAtUtc, + DateTimeOffset completedAtUtc, + string? processOutput = null) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.OutputPath)) + return null; + + string outputPath; + string? directory; + try + { + outputPath = Path.GetFullPath(request.OutputPath); + directory = Path.GetDirectoryName(outputPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + return null; + + var candidates = EnumerateCandidates(directory, startedAtUtc); + if (candidates.Count == 0) + return null; + + var baseName = ProfilingArtifactClassifier.GetBaseName(outputPath); + var primary = SelectPrimary(candidates, request.Format, baseName); + if (primary is null) + return null; + + var rawTrace = SelectBySuffix(candidates, NetTraceSuffix, baseName); + if (rawTrace is not null && PathsEqual(rawTrace, primary)) + rawTrace = null; + + return new MauiProfileResult + { + ProjectPath = request.ProjectPath, + ProjectName = Path.GetFileNameWithoutExtension(request.ProjectPath), + Framework = ExtractFramework(processOutput) ?? string.Empty, + Platform = ToPlatformName(request.Platform), + DeviceId = request.DeviceId, + DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) + ? request.DeviceId + : request.DeviceName, + Configuration = request.Configuration, + Format = DescribeFormat(primary), + OutputPath = primary, + RawTracePath = rawTrace, + UsedStoppingEvent = request.Mode == MauiProfileMode.Startup && request.Duration is null, + StartedAtUtc = startedAtUtc, + CompletedAtUtc = completedAtUtc, + RecoveredFromDisk = true + }; + } + + private static List EnumerateCandidates(string directory, DateTimeOffset startedAtUtc) + { + var earliestWrite = startedAtUtc - WriteTimeTolerance; + var candidates = new List(); + + IEnumerable files; + try + { + files = Directory.EnumerateFiles(directory); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return candidates; + } + + foreach (var file in files) + { + var fileName = Path.GetFileName(file); + if (!fileName.EndsWith(SpeedscopeSuffix, StringComparison.OrdinalIgnoreCase) && + !fileName.EndsWith(NetTraceSuffix, StringComparison.OrdinalIgnoreCase) && + !fileName.EndsWith(MibcSuffix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + FileInfo info; + try + { + info = new FileInfo(file); + if (!info.Exists || info.Length == 0) + continue; + if (info.LastWriteTimeUtc < earliestWrite) + continue; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + continue; + } + + candidates.Add(info.FullName); + } + + return candidates; + } + + private static string? SelectPrimary( + List candidates, + MauiProfileOutputFormat format, + string baseName) + { + // Fall back to the raw trace when the requested conversion did not produce a file: + // a captured trace is far more useful than discarding the run. + return format switch + { + MauiProfileOutputFormat.Speedscope => + SelectBySuffix(candidates, SpeedscopeSuffix, baseName) ?? + SelectBySuffix(candidates, NetTraceSuffix, baseName), + MauiProfileOutputFormat.Mibc => + SelectBySuffix(candidates, MibcSuffix, baseName) ?? + SelectBySuffix(candidates, NetTraceSuffix, baseName), + _ => SelectBySuffix(candidates, NetTraceSuffix, baseName) + }; + } + + private static string? SelectBySuffix(List candidates, string suffix, string baseName) + { + var matches = candidates + .Where(x => Path.GetFileName(x).EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (matches.Length == 0) + return null; + + var expected = matches.FirstOrDefault(x => string.Equals( + ProfilingArtifactClassifier.GetBaseName(x), + baseName, + StringComparison.OrdinalIgnoreCase)); + + return expected ?? matches + .OrderByDescending(x => File.GetLastWriteTimeUtc(x)) + .First(); + } + + private static string DescribeFormat(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(SpeedscopeSuffix, StringComparison.OrdinalIgnoreCase)) + return "speedscope"; + if (fileName.EndsWith(MibcSuffix, StringComparison.OrdinalIgnoreCase)) + return "mibc"; + return "nettrace"; + } + + private static string ToPlatformName(ProfilingTargetPlatform platform) => platform switch + { + ProfilingTargetPlatform.Android => "android", + ProfilingTargetPlatform.iOS => "ios", + _ => platform.ToString().ToLowerInvariant() + }; + + private static string? ExtractFramework(string? processOutput) + { + if (string.IsNullOrWhiteSpace(processOutput)) + return null; + + var match = FrameworkRegex().Match(processOutput); + return match.Success ? match.Value : null; + } + + private static bool PathsEqual(string left, string right) => + string.Equals(left, right, PathComparison); + + private static StringComparison PathComparison => + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + [GeneratedRegex(@"net\d+(?:\.\d+)+-[a-z][a-z0-9.]*", RegexOptions.IgnoreCase)] + private static partial Regex FrameworkRegex(); +} diff --git a/src/MauiSherpa.Core/Services/MauiProfileCommandBuilder.cs b/src/MauiSherpa.Core/Services/MauiProfileCommandBuilder.cs new file mode 100644 index 00000000..4f9bdd8b --- /dev/null +++ b/src/MauiSherpa.Core/Services/MauiProfileCommandBuilder.cs @@ -0,0 +1,119 @@ +using System.Globalization; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public static class MauiProfileCommandBuilder +{ + public const string StartupProviderName = "Microsoft.Maui.ProfilingHelper"; + public const string StartupEventName = "StartupComplete"; + + public static string[] BuildArguments(MauiProfileRequest request) + { + ArgumentNullException.ThrowIfNull(request); + Validate(request); + + var arguments = new List + { + "profile", + request.Mode == MauiProfileMode.Startup ? "startup" : "manual", + "--project", + request.ProjectPath, + "--platform", + ToPlatformArgument(request.Platform), + "--device", + request.DeviceId, + "--format", + ToFormatArgument(request.Format), + "--configuration", + request.Configuration, + "--output", + request.OutputPath + }; + + if (request.Mode == MauiProfileMode.Startup) + { + if (request.Duration is { } duration) + { + arguments.Add("--duration"); + arguments.Add(FormatDuration(duration)); + } + else + { + arguments.Add("--stopping-event-provider-name"); + arguments.Add(StartupProviderName); + arguments.Add("--stopping-event-event-name"); + arguments.Add(StartupEventName); + } + } + + if (!string.IsNullOrWhiteSpace(request.TraceProfile)) + { + arguments.Add("--trace-profile"); + arguments.Add(request.TraceProfile.Trim()); + } + + if (request.NoBuild) + arguments.Add("--no-build"); + + arguments.Add("--json"); + arguments.Add("--ci"); + + return [.. arguments]; + } + + public static string FormatForDisplay(string executablePath, MauiProfileRequest request) + { + var arguments = BuildArguments(request); + return string.Join(' ', [Quote(executablePath), .. arguments.Select(Quote)]); + } + + private static void Validate(MauiProfileRequest request) + { + if (string.IsNullOrWhiteSpace(request.ProjectPath)) + throw new ArgumentException("A MAUI project path is required.", nameof(request)); + if (request.Platform is not (ProfilingTargetPlatform.Android or ProfilingTargetPlatform.iOS)) + throw new ArgumentException("MAUI CLI profiling currently supports Android and iOS only.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.DeviceId)) + throw new ArgumentException("A running device or simulator is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.OutputPath)) + throw new ArgumentException("An output path is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.Configuration)) + throw new ArgumentException("A build configuration is required.", nameof(request)); + if (request.Duration is { } duration && duration <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(request), "Duration must be greater than zero."); + if (request.Mode == MauiProfileMode.Interaction && request.Duration is not null) + throw new ArgumentException("Duration is only supported for startup profiling.", nameof(request)); + } + + private static string ToPlatformArgument(ProfilingTargetPlatform platform) => platform switch + { + ProfilingTargetPlatform.Android => "android", + ProfilingTargetPlatform.iOS => "ios", + _ => throw new ArgumentOutOfRangeException(nameof(platform)) + }; + + private static string ToFormatArgument(MauiProfileOutputFormat format) => format switch + { + MauiProfileOutputFormat.NetTrace => "nettrace", + MauiProfileOutputFormat.Speedscope => "speedscope", + MauiProfileOutputFormat.Mibc => "mibc", + _ => throw new ArgumentOutOfRangeException(nameof(format)) + }; + + private static string FormatDuration(TimeSpan duration) + { + var totalHours = (int)duration.TotalHours; + return string.Create( + CultureInfo.InvariantCulture, + $"{totalHours:00}:{duration.Minutes:00}:{duration.Seconds:00}"); + } + + private static string Quote(string value) + { + if (value.Length > 0 && !value.Any(char.IsWhiteSpace) && !value.Contains('"')) + return value; + + return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + } +} diff --git a/src/MauiSherpa.Core/Services/MauiProfilingCliService.cs b/src/MauiSherpa.Core/Services/MauiProfilingCliService.cs new file mode 100644 index 00000000..9e63fd94 --- /dev/null +++ b/src/MauiSherpa.Core/Services/MauiProfilingCliService.cs @@ -0,0 +1,321 @@ +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public sealed class MauiProfilingCliService : IMauiProfilingCliService +{ + private readonly IProcessExecutionService _process; + private readonly IMauiCliToolService _toolService; + private readonly ILoggingService _logger; + private readonly object _sync = new(); + private readonly List _statusMessages = []; + + private MauiCliJsonStreamParser _parser = new(); + private MauiProfileResult? _profileResult; + private MauiCliErrorMessage? _error; + private MauiProfileRequest? _activeRequest; + private MauiProfileRunState _state = MauiProfileRunState.Idle; + private bool _disposed; + + public MauiProfileRunState State + { + get + { + lock (_sync) + return _state; + } + } + + public event EventHandler? StateChanged; + public event EventHandler? MessageReceived; + + public MauiProfilingCliService( + IProcessExecutionService process, + IMauiCliToolService toolService, + ILoggingService logger) + { + _process = process; + _toolService = toolService; + _logger = logger; + _process.OutputReceived += OnOutputReceived; + } + + public async Task RunAsync( + MauiProfileRequest request, + CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + lock (_sync) + { + if (_state is MauiProfileRunState.Starting or + MauiProfileRunState.AwaitingRecording or + MauiProfileRunState.Recording or + MauiProfileRunState.Finalizing) + { + throw new InvalidOperationException("A profiling session is already running."); + } + + _parser = new MauiCliJsonStreamParser(); + _profileResult = null; + _error = null; + _statusMessages.Clear(); + _activeRequest = request; + } + + var toolStatus = await _toolService.GetStatusAsync(ct); + if (!toolStatus.IsAvailable || string.IsNullOrWhiteSpace(toolStatus.ExecutablePath)) + throw new InvalidOperationException(toolStatus.Message ?? "The MAUI CLI is not ready."); + + SetState(MauiProfileRunState.Starting); + + var startedAtUtc = DateTimeOffset.UtcNow; + var arguments = MauiProfileCommandBuilder.BuildArguments(request); + var processTask = _process.ExecuteAsync( + new ProcessRequest( + toolStatus.ExecutablePath, + arguments, + WorkingDirectory: Path.GetDirectoryName(request.ProjectPath), + Title: request.Mode == MauiProfileMode.Startup + ? "Profiling app startup" + : "Profiling app interaction", + // Interaction captures drive the CLI's start/stop prompts over stdin. + AcceptsStandardInput: request.Mode == MauiProfileMode.Interaction), + ct); + + if (request.Mode == MauiProfileMode.Interaction) + SetState(MauiProfileRunState.AwaitingRecording); + + var processResult = await processTask; + + MauiProfileResult? profile; + MauiCliErrorMessage? error; + IReadOnlyList statusMessages; + lock (_sync) + { + profile = _profileResult; + error = _error; + statusMessages = _statusMessages.ToArray(); + _activeRequest = null; + } + + if (processResult.WasCancelled || processResult.ExitCode == 130) + { + SetState(MauiProfileRunState.Cancelled); + } + else if (!processResult.Success || error is not null || profile is null) + { + if (profile is null && IsRecoverableError(error)) + { + profile = TryRecoverProfile(request, startedAtUtc, processResult); + if (profile is not null) + error = null; + } + + if (profile is not null && error is null) + { + SetState(MauiProfileRunState.Completed); + } + else + { + error = NormalizeError(error, processResult); + SetState(MauiProfileRunState.Failed); + } + } + else + { + SetState(MauiProfileRunState.Completed); + } + + return new MauiProfileExecutionResult( + processResult, + profile, + error, + statusMessages); + } + + /// + /// The CLI reports its own result-serialization defect as a normal error envelope even + /// though the trace was already written, so that specific failure must not discard a + /// capture that exists on disk. + /// + private static bool IsRecoverableError(MauiCliErrorMessage? error) + { + if (error is null) + return true; + + return MauiProfileArtifactRecovery.IsResultSerializationFailure( + string.Join(Environment.NewLine, error.Message, error.NativeError)); + } + + private MauiProfileResult? TryRecoverProfile( + MauiProfileRequest request, + DateTimeOffset startedAtUtc, + ProcessResult processResult) + { + MauiProfileResult? recovered; + try + { + recovered = MauiProfileArtifactRecovery.TryRecover( + request, + startedAtUtc, + DateTimeOffset.UtcNow, + CombineOutput(processResult)); + } + catch (Exception ex) + { + _logger.LogError("Failed to recover a MAUI profile from disk.", ex); + return null; + } + + if (recovered is not null) + { + _logger.LogWarning( + $"The MAUI CLI exited with code {processResult.ExitCode} but wrote '{recovered.OutputPath}'. " + + "Recovering the capture from disk."); + } + + return recovered; + } + + private static MauiCliErrorMessage NormalizeError( + MauiCliErrorMessage? error, + ProcessResult processResult) + { + var combined = string.Join( + Environment.NewLine, + error?.Message, + error?.NativeError, + CombineOutput(processResult)); + + if (MauiProfileArtifactRecovery.IsResultSerializationFailure(combined)) + { + return new MauiCliErrorMessage( + "SHERPA_PROFILE_CLI_RESULT_SERIALIZATION", + "tool", + "error", + "The MAUI CLI failed while reporting its result and no profile was found on disk.", + error?.Message ?? processResult.Error, + new MauiCliRemediation( + "command", + "dotnet tool update -g Microsoft.Maui.Cli", + [ + "This is a defect in the installed Microsoft.Maui.Cli build.", + "Update the MAUI CLI, then run the capture again." + ])); + } + + return error ?? new MauiCliErrorMessage( + "SHERPA_PROFILE_RESULT", + "tool", + "error", + string.IsNullOrWhiteSpace(processResult.Error) + ? "The MAUI CLI did not return a profiling result." + : processResult.Error); + } + + private static string CombineOutput(ProcessResult processResult) => + string.Join(Environment.NewLine, processResult.Output, processResult.Error); + + public async Task BeginRecordingAsync(CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_activeRequest?.Mode != MauiProfileMode.Interaction || + State != MauiProfileRunState.AwaitingRecording) + { + throw new InvalidOperationException("The interaction profile is not waiting to begin recording."); + } + + await SendEnterAsync(ct); + SetState(MauiProfileRunState.Recording); + } + + public async Task StopRecordingAsync(CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_activeRequest?.Mode != MauiProfileMode.Interaction || + State != MauiProfileRunState.Recording) + { + throw new InvalidOperationException("No interaction profile is currently recording."); + } + + SetState(MauiProfileRunState.Finalizing); + await SendEnterAsync(ct); + } + + /// + /// The MAUI CLI advances its interactive prompts on a bare newline. + /// + private async Task SendEnterAsync(CancellationToken ct) + { + if (!await _process.SendInputAsync(Environment.NewLine, ct)) + throw new InvalidOperationException("The MAUI CLI is no longer accepting input."); + } + + public void Cancel() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (State is not (MauiProfileRunState.Starting or + MauiProfileRunState.AwaitingRecording or + MauiProfileRunState.Recording or + MauiProfileRunState.Finalizing)) + { + return; + } + + SetState(MauiProfileRunState.Cancelled); + _process.Cancel(); + } + + private void OnOutputReceived(object? sender, ProcessOutputEventArgs e) + { + IReadOnlyList messages; + lock (_sync) + { + messages = _parser.Append(e.Data); + foreach (var message in messages) + { + switch (message) + { + case MauiProfileResultMessage result: + _profileResult = result.Result; + break; + case MauiCliErrorMessage error: + _error = error; + break; + case MauiCliStatusMessage status: + _statusMessages.Add(status); + break; + } + } + } + + foreach (var message in messages) + MessageReceived?.Invoke(this, new MauiCliMessageEventArgs(message)); + } + + private void SetState(MauiProfileRunState state) + { + MauiProfileRunState oldState; + lock (_sync) + { + oldState = _state; + if (oldState == state) + return; + _state = state; + } + + _logger.LogDebug($"MAUI profile state: {oldState} -> {state}"); + StateChanged?.Invoke(this, new MauiProfileStateChangedEventArgs(oldState, state)); + } + + public void Dispose() + { + if (_disposed) + return; + + _process.OutputReceived -= OnOutputReceived; + _disposed = true; + } +} diff --git a/src/MauiSherpa.Core/Services/ProfilingArtifactClassifier.cs b/src/MauiSherpa.Core/Services/ProfilingArtifactClassifier.cs new file mode 100644 index 00000000..0b9c003e --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingArtifactClassifier.cs @@ -0,0 +1,73 @@ +using MauiSherpa.Core.Models.Profiling; + +namespace MauiSherpa.Core.Services; + +public static class ProfilingArtifactClassifier +{ + public static bool IsSupported(string path) + { + return Classify(path) != ProfilingArtifactKind.Other; + } + + public static ProfilingArtifactKind Classify(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + return ProfilingArtifactKind.Trace; + if (fileName.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + return ProfilingArtifactKind.Trace; + if (fileName.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)) + return ProfilingArtifactKind.Mibc; + if (fileName.EndsWith(".gcdump", StringComparison.OrdinalIgnoreCase)) + return ProfilingArtifactKind.GcDump; + if (fileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + return ProfilingArtifactKind.Log; + } + + return ProfilingArtifactKind.Other; + } + + public static string GetDisplayName(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + return "Speedscope profile"; + if (fileName.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + return "Raw .NET trace"; + if (fileName.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)) + return "MIBC startup profile"; + if (fileName.EndsWith(".gcdump", StringComparison.OrdinalIgnoreCase)) + return "GC heap dump"; + if (fileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + return "Capture log"; + } + + return fileName; + } + + public static string GetContentType(string path) + { + var fileName = Path.GetFileName(path); + if (fileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + return "application/json"; + if (fileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + return "text/plain"; + } + + return "application/octet-stream"; + } + + public static string GetBaseName(string path) + { + var fileName = Path.GetFileName(path); + return fileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase) + ? fileName[..^".speedscope.json".Length] + : Path.GetFileNameWithoutExtension(fileName); + } +} diff --git a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs b/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs deleted file mode 100644 index 1694cc58..00000000 --- a/src/MauiSherpa.Core/Services/ProfilingCaptureOrchestrationService.cs +++ /dev/null @@ -1,965 +0,0 @@ -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; - -namespace MauiSherpa.Core.Services; - -public class ProfilingCaptureOrchestrationService : IProfilingCaptureOrchestrationService -{ - private const string ProcessIdToken = "{{PROCESS_ID}}"; - private static readonly IReadOnlySet TraceCaptureKinds = new HashSet - { - ProfilingCaptureKind.Startup, - ProfilingCaptureKind.Cpu, - ProfilingCaptureKind.Network, - ProfilingCaptureKind.Rendering, - ProfilingCaptureKind.Energy, - ProfilingCaptureKind.SystemTrace - }; - - private readonly IProfilingCatalogService _profilingCatalogService; - private readonly IProfilingPrerequisitesService _profilingPrerequisitesService; - private readonly IDeviceMonitorService _deviceMonitorService; - private readonly IPlatformService _platformService; - private readonly IAndroidSdkSettingsService _androidSdkSettingsService; - private readonly ILoggingService _loggingService; - - public ProfilingCaptureOrchestrationService( - IProfilingCatalogService profilingCatalogService, - IProfilingPrerequisitesService profilingPrerequisitesService, - IDeviceMonitorService deviceMonitorService, - IPlatformService platformService, - IAndroidSdkSettingsService androidSdkSettingsService, - ILoggingService loggingService) - { - _profilingCatalogService = profilingCatalogService; - _profilingPrerequisitesService = profilingPrerequisitesService; - _deviceMonitorService = deviceMonitorService; - _platformService = platformService; - _androidSdkSettingsService = androidSdkSettingsService; - _loggingService = loggingService; - } - - public async Task PlanCaptureAsync( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions? options = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(definition); - - var normalizedOptions = NormalizeOptions(definition, options); - var targetFramework = ResolveTargetFramework(definition.Target.Platform, normalizedOptions.TargetFramework); - var workingDirectory = ResolveWorkingDirectory(normalizedOptions); - var capabilities = await _profilingCatalogService.GetCapabilitiesAsync(definition.Target.Platform, ct); - var definitionValidation = _profilingCatalogService.ValidateSessionDefinition(definition, capabilities); - var prerequisites = await _profilingPrerequisitesService.GetPrerequisitesAsync( - definition.Target.Platform, - definition.CaptureKinds, - workingDirectory, - ct); - - var errors = new List(definitionValidation.Errors); - var warnings = new List(); - var commands = new List(); - var runtimeBindings = new List(); - var expectedArtifacts = new List(); - var metadata = CreatePlanMetadata(definition, normalizedOptions, targetFramework); - - AppendPrerequisiteFindings(prerequisites, errors, warnings); - - if (normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Launch && - string.IsNullOrWhiteSpace(normalizedOptions.ProjectPath)) - { - errors.Add("A project path is required to plan build and launch steps."); - } - - var isTargetCurrentlyAvailable = IsTargetCurrentlyAvailable(definition.Target); - if (!isTargetCurrentlyAvailable && RequiresConnectedTarget(definition.Target)) - { - warnings.Add($"Target '{definition.Target.Identifier}' is not currently present in the connected device snapshot."); - } - - if (normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Attach && - !capabilities.SupportsAttachToProcess) - { - errors.Add($"{capabilities.DisplayName} capabilities do not support attach flows."); - } - - var diagnostics = BuildDiagnosticsConfiguration(definition.Target, normalizedOptions, _platformService.IsWindows); - var traceArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, "trace.nettrace"); - var gcdumpArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, "memory.gcdump"); - var logsArtifactPath = Path.Combine(normalizedOptions.OutputDirectory!, "logs.txt"); - - var androidSdkPath = definition.Target.Platform == ProfilingTargetPlatform.Android - ? await TryGetAndroidSdkPathAsync() - : null; - - // Modern dotnet-trace/dotnet-gcdump support --dsrouter natively, so we no longer - // need a standalone dotnet-dsrouter process. However, only ONE tool can use --dsrouter - // at a time because each starts its own dsrouter instance. When both trace and gcdump - // are requested on a mobile target, we fall back to a standalone dsrouter process and - // have both tools connect via --diagnostic-port using the IPC address instead. - var hasTraceCapture = definition.CaptureKinds.Any(kind => TraceCaptureKinds.Contains(kind)); - var hasMemoryCapture = definition.CaptureKinds.Contains(ProfilingCaptureKind.Memory); - var hasLogCapture = definition.CaptureKinds.Contains(ProfilingCaptureKind.Logs); - var dsrouterPlatformArg = GetDsRouterPlatformArg(definition.Target); - var isMobileTarget = dsrouterPlatformArg is not null; - // Both trace and gcdump are now on-demand, so always use standalone dsrouter - // on mobile when either is requested — they need to share the diagnostic port. - var needsStandaloneDsRouter = isMobileTarget && (hasTraceCapture || hasMemoryCapture); - - // If we need standalone dsrouter, clear the inline arg so capture steps use --diagnostic-port instead - if (needsStandaloneDsRouter) - dsrouterPlatformArg = null; - - var preLaunchCaptureSteps = new List(); - var postLaunchCaptureSteps = new List(); - - // When both trace and gcdump target a mobile platform, start a standalone dsrouter - // and have both tools connect to it via --diagnostic-port using the IPC socket. - if (needsStandaloneDsRouter && diagnostics is not null) - { - commands.Add(CreateDsRouterStep(definition, diagnostics, normalizedOptions, androidSdkPath)); - } - - if (hasTraceCapture) - { - // Don't add traceStep to pipeline — trace is an on-demand action - // triggered by the user via Start Trace / Stop Trace buttons. - // This prevents trace from auto-starting and competing with gcdump - // for the diagnostic port. - var (_, traceArtifact) = CreateTraceCaptureStep( - definition, - normalizedOptions, - dsrouterPlatformArg, - traceArtifactPath, - runtimeBindings, - needsStandaloneDsRouter ? diagnostics?.IpcAddress : null, - androidSdkPath); - - expectedArtifacts.Add(traceArtifact); - } - - if (normalizedOptions.LaunchMode == ProfilingCaptureLaunchMode.Launch) - { - commands.AddRange(preLaunchCaptureSteps); - - // Android requires adb setup steps before the app launches: - // - Physical devices need adb reverse for port forwarding - // - All Android targets need debug.mono.profile system property set - if (definition.Target.Platform == ProfilingTargetPlatform.Android && diagnostics is not null) - { - commands.AddRange(CreateAndroidDiagnosticSetupSteps( - definition.Target, diagnostics, normalizedOptions, androidSdkPath)); - } - - commands.Add(CreateLaunchStep(definition, normalizedOptions, targetFramework, workingDirectory, diagnostics, androidSdkPath)); - } - - if (!isMobileTarget && - normalizedOptions.ProcessId is null && - (hasTraceCapture || hasMemoryCapture)) - { - commands.Add(CreateProcessDiscoveryStep(definition, normalizedOptions)); - if (runtimeBindings.All(binding => binding.Token != ProcessIdToken)) - { - runtimeBindings.Add(new ProfilingRuntimeBinding( - ProcessIdToken, - "Resolve the local desktop process id after the app is running.", - ExampleValue: "12345")); - } - } - - if (hasMemoryCapture) - { - var (_, memoryArtifact) = CreateMemoryCaptureStep( - definition, - normalizedOptions, - dsrouterPlatformArg, - gcdumpArtifactPath, - runtimeBindings, - needsStandaloneDsRouter ? diagnostics?.IpcAddress : null, - androidSdkPath, - hasTraceCapture); - - // Don't add memoryStep to pipeline — GC dump is a point-in-time snapshot - // that users trigger on demand via the capture UI, not auto-run. - expectedArtifacts.Add(memoryArtifact); - } - - if (hasLogCapture) - { - var logStep = CreateLogCaptureStep(definition, normalizedOptions, logsArtifactPath, androidSdkPath); - if (logStep is not null) - { - postLaunchCaptureSteps.Add(logStep); - expectedArtifacts.Add(new ProfilingArtifactMetadata( - Id: $"{definition.Id}-logs", - SessionId: definition.Id, - Kind: ProfilingArtifactKind.Logs, - DisplayName: "Streaming logs", - FileName: Path.GetFileName(logsArtifactPath), - RelativePath: logsArtifactPath, - ContentType: "text/plain", - CreatedAt: DateTimeOffset.UtcNow, - Properties: CreateArtifactProperties(definition, "logs"))); - } - else - { - warnings.Add($"Logs capture planning is not yet modeled for {capabilities.DisplayName} {definition.Target.Kind} targets."); - } - } - - commands.AddRange(postLaunchCaptureSteps); - - var validation = new ProfilingPlanValidation( - Errors: errors - .Where(error => !string.IsNullOrWhiteSpace(error)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(), - Warnings: warnings - .Where(warning => !string.IsNullOrWhiteSpace(warning)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray()); - - if (!validation.IsValid) - { - _loggingService.LogDebug( - $"Profiling capture plan for session '{definition.Id}' contains validation issues: {string.Join(" | ", validation.Errors)}"); - } - - return new ProfilingCapturePlan( - definition, - capabilities, - normalizedOptions, - _platformService.PlatformName, - targetFramework, - normalizedOptions.OutputDirectory!, - workingDirectory, - isTargetCurrentlyAvailable, - diagnostics, - prerequisites, - validation, - runtimeBindings.ToArray(), - commands.ToArray(), - expectedArtifacts.ToArray(), - metadata); - } - - private static ProfilingCapturePlanOptions NormalizeOptions( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions? options) - { - var normalized = options ?? new ProfilingCapturePlanOptions(); - var configuration = string.IsNullOrWhiteSpace(normalized.Configuration) ? "Release" : normalized.Configuration.Trim(); - var projectPath = string.IsNullOrWhiteSpace(normalized.ProjectPath) ? null : normalized.ProjectPath.Trim(); - var workingDirectory = string.IsNullOrWhiteSpace(normalized.WorkingDirectory) ? null : normalized.WorkingDirectory.Trim(); - var effectiveWorkingDirectory = workingDirectory ?? (string.IsNullOrWhiteSpace(projectPath) ? null : Path.GetDirectoryName(projectPath)); - var outputDirectory = string.IsNullOrWhiteSpace(normalized.OutputDirectory) - ? BuildDefaultOutputDirectory(normalized.ProjectPath, definition.CreatedAt) - : normalized.OutputDirectory.Trim(); - - // Make the output directory absolute so that artifact collection in the runner - // (which may run with a different CWD) can always find the files. - if (!Path.IsPathRooted(outputDirectory)) - { - var resolveBase = effectiveWorkingDirectory ?? Directory.GetCurrentDirectory(); - outputDirectory = Path.GetFullPath(Path.Combine(resolveBase, outputDirectory)); - } - var additionalBuildProperties = normalized.AdditionalBuildProperties is null - ? null - : new Dictionary(normalized.AdditionalBuildProperties, StringComparer.OrdinalIgnoreCase); - - return normalized with - { - ProjectPath = projectPath, - Configuration = configuration, - WorkingDirectory = effectiveWorkingDirectory, - OutputDirectory = outputDirectory, - AdditionalBuildProperties = additionalBuildProperties - }; - } - - private static string BuildDefaultOutputDirectory(string? projectPath, DateTimeOffset createdAt) - { - var projectName = "session"; - if (!string.IsNullOrWhiteSpace(projectPath)) - { - projectName = Path.GetFileNameWithoutExtension(projectPath); - } - - var dateStr = createdAt == default - ? DateTime.Now.ToString("yyyy-MM-dd") - : createdAt.LocalDateTime.ToString("yyyy-MM-dd"); - var baseDir = Path.Combine("artifacts", "profiling", projectName); - - var runNumber = 1; - if (Directory.Exists(baseDir)) - { - var prefix = $"{dateStr}-"; - var existingRuns = Directory.GetDirectories(baseDir) - .Select(d => Path.GetFileName(d)) - .Where(name => name!.StartsWith(prefix, StringComparison.Ordinal)) - .Select(name => { - var suffix = name!.Substring(prefix.Length); - return int.TryParse(suffix, out var n) ? n : 0; - }) - .Where(n => n > 0) - .ToList(); - - if (existingRuns.Count > 0) - runNumber = existingRuns.Max() + 1; - } - - return Path.Combine(baseDir, $"{dateStr}-{runNumber}"); - } - - private static string ResolveTargetFramework(ProfilingTargetPlatform platform, string? targetFrameworkOverride) => - string.IsNullOrWhiteSpace(targetFrameworkOverride) - ? platform switch - { - ProfilingTargetPlatform.Android => "net10.0-android", - ProfilingTargetPlatform.iOS => "net10.0-ios", - ProfilingTargetPlatform.MacCatalyst => "net10.0-maccatalyst", - ProfilingTargetPlatform.MacOS => "net10.0-macos", - ProfilingTargetPlatform.Windows => "net10.0-windows10.0.19041.0", - _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported profiling platform.") - } - : targetFrameworkOverride.Trim(); - - private static string? ResolveWorkingDirectory(ProfilingCapturePlanOptions options) - { - if (!string.IsNullOrWhiteSpace(options.WorkingDirectory)) - return options.WorkingDirectory; - - return string.IsNullOrWhiteSpace(options.ProjectPath) - ? null - : Path.GetDirectoryName(options.ProjectPath); - } - - private static IReadOnlyDictionary CreatePlanMetadata( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions options, - string targetFramework) - { - var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["sessionId"] = definition.Id, - ["targetPlatform"] = definition.Target.Platform.ToString(), - ["targetKind"] = definition.Target.Kind.ToString(), - ["targetIdentifier"] = definition.Target.Identifier, - ["configuration"] = options.Configuration, - ["launchMode"] = options.LaunchMode.ToString(), - ["targetFramework"] = targetFramework, - ["outputDirectory"] = options.OutputDirectory ?? string.Empty - }; - - if (!string.IsNullOrWhiteSpace(options.ProjectPath)) - metadata["projectPath"] = options.ProjectPath; - - return metadata; - } - - private static void AppendPrerequisiteFindings( - ProfilingPrerequisiteReport prerequisites, - List errors, - List warnings) - { - foreach (var check in prerequisites.Checks.Where(check => check.IsRequired && check.Status == DependencyStatusType.Error)) - { - errors.Add(check.Message ?? $"{check.Name} is required for profiling orchestration."); - } - } - - private bool IsTargetCurrentlyAvailable(ProfilingTarget target) - { - var snapshot = _deviceMonitorService.Current; - - return target.Platform switch - { - ProfilingTargetPlatform.Android when target.Kind == ProfilingTargetKind.PhysicalDevice - => snapshot.AndroidDevices.Any(device => string.Equals(device.Serial, target.Identifier, StringComparison.OrdinalIgnoreCase)), - ProfilingTargetPlatform.Android when target.Kind == ProfilingTargetKind.Emulator - => snapshot.AndroidEmulators.Any(device => string.Equals(device.Serial, target.Identifier, StringComparison.OrdinalIgnoreCase)), - ProfilingTargetPlatform.iOS when target.Kind == ProfilingTargetKind.PhysicalDevice - => snapshot.ApplePhysicalDevices.Any(device => string.Equals(device.Identifier, target.Identifier, StringComparison.OrdinalIgnoreCase)), - ProfilingTargetPlatform.iOS when target.Kind == ProfilingTargetKind.Simulator - => snapshot.BootedSimulators.Any(device => string.Equals(device.Identifier, target.Identifier, StringComparison.OrdinalIgnoreCase)), - _ => true - }; - } - - private static bool RequiresConnectedTarget(ProfilingTarget target) => - (target.Platform == ProfilingTargetPlatform.Android && - target.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Emulator) - || (target.Platform == ProfilingTargetPlatform.iOS && - target.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Simulator); - - private static ProfilingDiagnosticConfiguration? BuildDiagnosticsConfiguration( - ProfilingTarget target, - ProfilingCapturePlanOptions options, - bool isWindowsHost) - { - if (target.Platform is not ProfilingTargetPlatform.Android and not ProfilingTargetPlatform.iOS) - return null; - - var ipcAddress = isWindowsHost - ? $@"\\.\pipe\maui-sherpa-profile-{Guid.NewGuid():N}" - : Path.Combine(Path.GetTempPath(), $"ms-prof-{Guid.NewGuid().ToString("N")[..8]}.sock"); - var tcpEndpoint = $"127.0.0.1:{options.DiagnosticPort}"; - - return target.Platform switch - { - ProfilingTargetPlatform.Android => new ProfilingDiagnosticConfiguration( - Address: target.Kind == ProfilingTargetKind.Emulator ? "10.0.2.2" : "127.0.0.1", - Port: options.DiagnosticPort, - ListenMode: ProfilingDiagnosticListenMode.Connect, - SuspendOnStartup: options.SuspendAtStartup, - RequiresDsRouter: true, - DsRouterMode: ProfilingDsRouterMode.ServerServer, - DsRouterPortForwardPlatform: "Android", - IpcAddress: ipcAddress, - TcpEndpoint: tcpEndpoint), - ProfilingTargetPlatform.iOS => new ProfilingDiagnosticConfiguration( - Address: "127.0.0.1", - Port: options.DiagnosticPort, - ListenMode: ProfilingDiagnosticListenMode.Listen, - SuspendOnStartup: options.SuspendAtStartup, - RequiresDsRouter: true, - DsRouterMode: ProfilingDsRouterMode.ServerClient, - DsRouterPortForwardPlatform: target.Kind == ProfilingTargetKind.PhysicalDevice ? "iOS" : null, - IpcAddress: ipcAddress, - TcpEndpoint: tcpEndpoint), - _ => null - }; - } - - /// - /// Creates a standalone dotnet-dsrouter process step. Kept as a fallback for environments - /// where the integrated --dsrouter flag in dotnet-trace/dotnet-gcdump is not available. - /// In the normal flow, --dsrouter is passed directly to the capture tools instead. - /// - private static ProfilingCommandStep CreateDsRouterStep( - ProfilingSessionDefinition definition, - ProfilingDiagnosticConfiguration diagnostics, - ProfilingCapturePlanOptions options, - string? androidSdkPath) - { - var arguments = new List - { - diagnostics.DsRouterMode == ProfilingDsRouterMode.ServerServer ? "server-server" : "server-client", - "-ipcs", - diagnostics.IpcAddress, - diagnostics.DsRouterMode == ProfilingDsRouterMode.ServerServer ? "-tcps" : "-tcpc", - diagnostics.TcpEndpoint, - "-rt", - Math.Max(30, (int)Math.Ceiling((definition.Duration ?? TimeSpan.FromMinutes(5)).TotalSeconds)).ToString() - }; - - if (!string.IsNullOrWhiteSpace(diagnostics.DsRouterPortForwardPlatform)) - { - arguments.Add("--forward-port"); - arguments.Add(diagnostics.DsRouterPortForwardPlatform); - } - - var environment = BuildAndroidEnvironment(definition.Target, androidSdkPath); - - return new ProfilingCommandStep( - Id: "start-dsrouter", - Kind: ProfilingCommandStepKind.Prepare, - DisplayName: "Start diagnostics router", - Description: "Start dotnet-dsrouter so local diagnostic tools can talk to the remote mobile runtime.", - Command: "dotnet-dsrouter", - Arguments: arguments, - WorkingDirectory: options.WorkingDirectory, - Environment: environment, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet-dsrouter", - ["mode"] = diagnostics.DsRouterMode.ToString(), - ["ipcAddress"] = diagnostics.IpcAddress, - ["tcpEndpoint"] = diagnostics.TcpEndpoint, - ["portForward"] = diagnostics.DsRouterPortForwardPlatform ?? string.Empty - }, - IsLongRunning: true, - RequiresManualStop: true, - ReadyOutputPattern: "Starting IPC server"); - } - - private static ProfilingCommandStep CreateLaunchStep( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions options, - string targetFramework, - string? workingDirectory, - ProfilingDiagnosticConfiguration? diagnostics, - string? androidSdkPath = null) - { - var arguments = new List - { - "build" - }; - - if (!string.IsNullOrWhiteSpace(options.ProjectPath)) - arguments.Add(options.ProjectPath); - - arguments.Add("-t:Run"); - arguments.Add("-c"); - arguments.Add(options.Configuration); - arguments.Add("-f"); - arguments.Add(targetFramework); - - // Android requires AndroidEnableProfiler=true to include the Mono diagnostic component. - // The runtime diagnostic port is configured via adb system properties, not MSBuild properties. - if (diagnostics is not null && definition.Target.Platform == ProfilingTargetPlatform.Android) - { - arguments.Add("-p:AndroidEnableProfiler=true"); - } - - if (options.AdditionalBuildProperties is not null) - { - foreach (var buildProperty in options.AdditionalBuildProperties.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) - { - arguments.Add($"-p:{buildProperty.Key}={buildProperty.Value}"); - } - } - - var environment = definition.Target.Platform == ProfilingTargetPlatform.Android - ? BuildAndroidEnvironment(definition.Target, androidSdkPath) - : null; - - return new ProfilingCommandStep( - Id: "build-and-run", - Kind: ProfilingCommandStepKind.Launch, - DisplayName: "Build and run target app", - Description: $"Build and launch {definition.Target.DisplayName} using {targetFramework}.", - Command: "dotnet", - Arguments: arguments, - WorkingDirectory: workingDirectory, - Environment: environment, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet", - ["targetFramework"] = targetFramework, - ["configuration"] = options.Configuration, - ["targetIdentifier"] = definition.Target.Identifier, - ["launchMode"] = options.LaunchMode.ToString() - }, - IsLongRunning: true, - RequiresManualStop: definition.Target.Platform is ProfilingTargetPlatform.MacCatalyst or ProfilingTargetPlatform.MacOS or ProfilingTargetPlatform.Windows, - CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.OnPipelineStop, - ReadyOutputPattern: "Build succeeded"); - } - - /// - /// Creates Android-specific setup steps that must run before the app launches: - /// 1. For physical devices: adb reverse to forward the diagnostic TCP port - /// 2. adb shell setprop to configure the Mono diagnostic port on the device/emulator - /// - private static List CreateAndroidDiagnosticSetupSteps( - ProfilingTarget target, - ProfilingDiagnosticConfiguration diagnostics, - ProfilingCapturePlanOptions options, - string? androidSdkPath) - { - var steps = new List(); - var environment = BuildAndroidEnvironment(target, androidSdkPath); - var suspendMode = diagnostics.SuspendOnStartup ? "suspend" : "nosuspend"; - - // Physical devices need adb reverse to forward the TCP port from device to host - if (target.Kind == ProfilingTargetKind.PhysicalDevice) - { - steps.Add(new ProfilingCommandStep( - Id: "setup-adb-reverse", - Kind: ProfilingCommandStepKind.Prepare, - DisplayName: "Forward diagnostic port", - Description: $"Set up adb reverse port forwarding so the device can reach the host diagnostic router on port {diagnostics.Port}.", - Command: "adb", - Arguments: ["reverse", $"tcp:{diagnostics.Port}", $"tcp:{diagnostics.Port + 1}"], - WorkingDirectory: options.WorkingDirectory, - Environment: environment, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "adb", - ["port"] = diagnostics.Port.ToString() - })); - } - - // Set the debug.mono.profile system property so the app runtime connects to the diagnostic router - var diagnosticAddress = $"{diagnostics.Address}:{diagnostics.Port},{suspendMode},connect"; - steps.Add(new ProfilingCommandStep( - Id: "setup-diagnostic-port", - Kind: ProfilingCommandStepKind.Prepare, - DisplayName: "Configure diagnostic port", - Description: $"Set Android system property debug.mono.profile to '{diagnosticAddress}' so the app runtime connects to the diagnostic router.", - Command: "adb", - Arguments: ["shell", "setprop", "debug.mono.profile", diagnosticAddress], - WorkingDirectory: options.WorkingDirectory, - Environment: environment, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "adb", - ["diagnosticAddress"] = diagnosticAddress, - ["suspendMode"] = suspendMode - })); - - return steps; - } - - private static ProfilingCommandStep CreateProcessDiscoveryStep( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions options) - { - return new ProfilingCommandStep( - Id: "discover-process-id", - Kind: ProfilingCommandStepKind.DiscoverProcess, - DisplayName: "Discover target process id", - Description: $"List local .NET processes and bind {ProcessIdToken} to the running {definition.Target.DisplayName} process before attaching.", - Command: "dotnet-trace", - Arguments: ["ps"], - WorkingDirectory: options.WorkingDirectory, - RequiredRuntimeBindings: [ProcessIdToken], - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet-trace", - ["runtimeBinding"] = ProcessIdToken - }); - } - - private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) CreateTraceCaptureStep( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions options, - string? dsrouterPlatformArg, - string traceArtifactPath, - List runtimeBindings, - string? diagnosticPortAddress = null, - string? androidSdkPath = null) - { - var traceKinds = definition.CaptureKinds - .Where(kind => TraceCaptureKinds.Contains(kind)) - .Select(kind => kind.ToString()) - .ToArray(); - var arguments = new List - { - "collect" - }; - - if (dsrouterPlatformArg is not null) - { - arguments.Add("--dsrouter"); - arguments.Add(dsrouterPlatformArg); - } - else if (diagnosticPortAddress is not null) - { - // Connect to a standalone dsrouter via its IPC address (connect mode, not listen) - arguments.Add("--diagnostic-port"); - arguments.Add($"{diagnosticPortAddress},connect"); - } - else - { - arguments.Add("--process-id"); - arguments.Add(options.ProcessId?.ToString() ?? ProcessIdToken); - if (options.ProcessId is null) - { - runtimeBindings.Add(new ProfilingRuntimeBinding( - ProcessIdToken, - "Resolve the process id before starting dotnet-trace.", - ExampleValue: "12345")); - } - } - - arguments.Add("--output"); - arguments.Add(traceArtifactPath); - - // Map capture kinds to dotnet-trace profiles for meaningful data. - // "dotnet-sampled-thread-time" samples managed stacks at ~100Hz (works on all platforms). - // "cpu-sampling" and "thread-time" are Linux-only (collect-linux). - var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var kind in definition.CaptureKinds.Where(k => TraceCaptureKinds.Contains(k))) - { - switch (kind) - { - case ProfilingCaptureKind.Cpu: - case ProfilingCaptureKind.Startup: - profiles.Add("dotnet-sampled-thread-time"); - break; - case ProfilingCaptureKind.Rendering: - case ProfilingCaptureKind.Network: - case ProfilingCaptureKind.Energy: - case ProfilingCaptureKind.SystemTrace: - profiles.Add("dotnet-common"); - break; - } - } - if (profiles.Count == 0) - profiles.Add("dotnet-sampled-thread-time"); - arguments.Add("--profile"); - arguments.Add(string.Join(",", profiles)); - - // Add JIT/Loader provider flags for managed symbol resolution in speedscope. - // 0x10000018 = JitTracing | NGenTracing | Loader keywords, Verbose level (5). - arguments.Add("--providers"); - arguments.Add("Microsoft-Windows-DotNETRuntime:0x10000018:5"); - - var dependsOn = new List(); - if (diagnosticPortAddress is not null) - dependsOn.Add("start-dsrouter"); - if (dsrouterPlatformArg is null && diagnosticPortAddress is null && options.ProcessId is null) - dependsOn.Add("discover-process-id"); - - return ( - new ProfilingCommandStep( - Id: "capture-trace", - Kind: ProfilingCommandStepKind.Capture, - DisplayName: "Collect trace", - Description: $"Collect a trace for {string.Join(", ", traceKinds)} captures.", - Command: "dotnet-trace", - Arguments: arguments, - WorkingDirectory: options.WorkingDirectory, - Environment: (dsrouterPlatformArg is not null || diagnosticPortAddress is not null) - ? BuildAndroidEnvironment(definition.Target, androidSdkPath) : null, - DependsOn: dependsOn.Count > 0 ? dependsOn : null, - RequiredRuntimeBindings: dsrouterPlatformArg is null && diagnosticPortAddress is null && options.ProcessId is null ? [ProcessIdToken] : null, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet-trace", - ["captureKinds"] = string.Join(",", traceKinds), - ["output"] = traceArtifactPath - }, - IsLongRunning: true, - RequiresManualStop: true, - CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.ManualStop, - ReadyOutputPattern: "Process"), - new ProfilingArtifactMetadata( - Id: $"{definition.Id}-trace", - SessionId: definition.Id, - Kind: ProfilingArtifactKind.Trace, - DisplayName: "Trace capture", - FileName: Path.GetFileName(traceArtifactPath), - RelativePath: traceArtifactPath, - ContentType: "application/json", - CreatedAt: DateTimeOffset.UtcNow, - Properties: CreateArtifactProperties(definition, "trace"))); - } - - private static (ProfilingCommandStep Step, ProfilingArtifactMetadata Artifact) CreateMemoryCaptureStep( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions options, - string? dsrouterPlatformArg, - string gcdumpArtifactPath, - List runtimeBindings, - string? diagnosticPortAddress = null, - string? androidSdkPath = null, - bool hasTraceCapture = false) - { - var arguments = new List - { - "collect" - }; - - if (dsrouterPlatformArg is not null) - { - arguments.Add("--dsrouter"); - arguments.Add(dsrouterPlatformArg); - } - else if (diagnosticPortAddress is not null) - { - // Connect to a standalone dsrouter via its IPC address (connect mode, not listen) - arguments.Add("--diagnostic-port"); - arguments.Add($"{diagnosticPortAddress},connect"); - } - else - { - arguments.Add("--process-id"); - arguments.Add(options.ProcessId?.ToString() ?? ProcessIdToken); - if (options.ProcessId is null && runtimeBindings.All(binding => binding.Token != ProcessIdToken)) - { - runtimeBindings.Add(new ProfilingRuntimeBinding( - ProcessIdToken, - "Resolve the process id before collecting a GC dump.", - ExampleValue: "12345")); - } - } - - arguments.Add("-o"); - arguments.Add(gcdumpArtifactPath); - - var dependsOn = new List(); - if (options.LaunchMode == ProfilingCaptureLaunchMode.Launch) - dependsOn.Add("build-and-run"); - if (diagnosticPortAddress is not null) - dependsOn.Add("start-dsrouter"); - if (dsrouterPlatformArg is null && diagnosticPortAddress is null && options.ProcessId is null) - dependsOn.Add("discover-process-id"); - // When both trace and memory are requested, wait for the trace step to - // establish its diagnostic port connection before collecting the GC dump. - if (hasTraceCapture) - dependsOn.Add("capture-trace"); - - return ( - new ProfilingCommandStep( - Id: "capture-memory", - Kind: ProfilingCommandStepKind.CollectArtifacts, - DisplayName: "Collect GC dump", - Description: "Collect a managed memory dump using dotnet-gcdump.", - Command: "dotnet-gcdump", - Arguments: arguments, - WorkingDirectory: options.WorkingDirectory, - Environment: (dsrouterPlatformArg is not null || diagnosticPortAddress is not null) - ? BuildAndroidEnvironment(definition.Target, androidSdkPath) : null, - DependsOn: dependsOn.Count > 0 ? dependsOn : null, - RequiredRuntimeBindings: dsrouterPlatformArg is null && diagnosticPortAddress is null && options.ProcessId is null ? [ProcessIdToken] : null, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet-gcdump", - ["output"] = gcdumpArtifactPath - }), - new ProfilingArtifactMetadata( - Id: $"{definition.Id}-memory", - SessionId: definition.Id, - Kind: ProfilingArtifactKind.Export, - DisplayName: "GC dump", - FileName: Path.GetFileName(gcdumpArtifactPath), - RelativePath: gcdumpArtifactPath, - ContentType: "application/octet-stream", - CreatedAt: DateTimeOffset.UtcNow, - Properties: CreateArtifactProperties(definition, "memory"))); - } - - private static ProfilingCommandStep? CreateLogCaptureStep( - ProfilingSessionDefinition definition, - ProfilingCapturePlanOptions options, - string logsArtifactPath, - string? androidSdkPath) - { - switch (definition.Target.Platform, definition.Target.Kind) - { - case (ProfilingTargetPlatform.Android, ProfilingTargetKind.PhysicalDevice): - case (ProfilingTargetPlatform.Android, ProfilingTargetKind.Emulator): - Dictionary? environment = null; - if (!string.IsNullOrWhiteSpace(androidSdkPath)) - { - environment = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["ANDROID_HOME"] = androidSdkPath - }; - } - - return new ProfilingCommandStep( - Id: "capture-logs", - Kind: ProfilingCommandStepKind.Capture, - DisplayName: "Stream Android logs", - Description: $"Stream adb logcat output for {definition.Target.DisplayName}. Redirect output to {logsArtifactPath}.", - Command: "adb", - Arguments: ["-s", definition.Target.Identifier, "logcat", "-v", "threadtime"], - WorkingDirectory: options.WorkingDirectory, - Environment: environment, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "adb", - ["outputHint"] = logsArtifactPath - }, - IsLongRunning: true, - RequiresManualStop: true, - CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.OnPipelineStop); - - case (ProfilingTargetPlatform.iOS, ProfilingTargetKind.Simulator): - return new ProfilingCommandStep( - Id: "capture-logs", - Kind: ProfilingCommandStepKind.Capture, - DisplayName: "Stream simulator logs", - Description: $"Stream simulator logs for {definition.Target.DisplayName}. Redirect output to {logsArtifactPath}.", - Command: "xcrun", - Arguments: ["simctl", "spawn", definition.Target.Identifier, "log", "stream", "--style", "ndjson", "--level", "debug"], - WorkingDirectory: options.WorkingDirectory, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "xcrun", - ["outputHint"] = logsArtifactPath - }, - IsLongRunning: true, - RequiresManualStop: true, - CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.OnPipelineStop); - - case (ProfilingTargetPlatform.iOS, ProfilingTargetKind.PhysicalDevice): - return new ProfilingCommandStep( - Id: "capture-logs", - Kind: ProfilingCommandStepKind.Capture, - DisplayName: "Stream device logs", - Description: $"Stream physical device logs for {definition.Target.DisplayName}. Redirect output to {logsArtifactPath}.", - Command: "idevicesyslog", - Arguments: ["-u", definition.Target.Identifier], - WorkingDirectory: options.WorkingDirectory, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "idevicesyslog", - ["outputHint"] = logsArtifactPath, - ["alternativeTool"] = "pymobiledevice3 syslog live --udid " - }, - IsLongRunning: true, - RequiresManualStop: true, - CanRunParallel: true, - StopTrigger: ProfilingStopTrigger.OnPipelineStop); - - default: - return null; - } - } - - private async Task TryGetAndroidSdkPathAsync() - { - try - { - return await _androidSdkSettingsService.GetEffectiveSdkPathAsync(); - } - catch (Exception ex) - { - _loggingService.LogDebug($"Failed to resolve Android SDK path for profiling orchestration: {ex.Message}"); - return null; - } - } - - private static string? GetDsRouterPlatformArg(ProfilingTarget target) => - (target.Platform, target.Kind) switch - { - (ProfilingTargetPlatform.Android, ProfilingTargetKind.Emulator) => "android-emu", - (ProfilingTargetPlatform.Android, ProfilingTargetKind.PhysicalDevice) => "android", - (ProfilingTargetPlatform.iOS, ProfilingTargetKind.Simulator) => "ios-sim", - (ProfilingTargetPlatform.iOS, ProfilingTargetKind.PhysicalDevice) => "ios", - _ => null - }; - - /// - /// Build environment variables for Android targets so that adb/dsrouter target - /// the correct device when multiple devices or emulators are connected. - /// - private static Dictionary? BuildAndroidEnvironment( - ProfilingTarget target, - string? androidSdkPath) - { - if (target.Platform != ProfilingTargetPlatform.Android) - return null; - - var env = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrWhiteSpace(androidSdkPath)) - env["ANDROID_HOME"] = androidSdkPath; - if (!string.IsNullOrWhiteSpace(target.Identifier)) - env["ANDROID_SERIAL"] = target.Identifier; - return env.Count > 0 ? env : null; - } - - private static IReadOnlyDictionary CreateArtifactProperties( - ProfilingSessionDefinition definition, - string category) - { - return new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["targetPlatform"] = definition.Target.Platform.ToString(), - ["targetIdentifier"] = definition.Target.Identifier, - ["scenario"] = definition.Scenario.ToString(), - ["category"] = category - }; - } -} diff --git a/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs b/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs index 29b0139d..6b08e64b 100644 --- a/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs +++ b/src/MauiSherpa.Core/Services/ProfilingCatalogService.cs @@ -10,37 +10,16 @@ public class ProfilingCatalogService : IProfilingCatalogService { [ProfilingScenarioKind.Launch] = new( ProfilingScenarioKind.Launch, - "Launch & startup", - "Capture cold or warm start behavior with startup, CPU, and memory signals.", - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory], + "Startup", + "Capture from process start until the first MAUI UI is ready.", + [ProfilingCaptureKind.Startup], TimeSpan.FromMinutes(2)), [ProfilingScenarioKind.Interaction] = new( ProfilingScenarioKind.Interaction, - "Interaction trace", - "Focus on a bounded interaction such as tapping through a flow or completing a task.", - [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Memory], + "Interaction", + "Launch the app, navigate to a starting point, then explicitly begin and stop recording.", + [ProfilingCaptureKind.Interaction], TimeSpan.FromMinutes(5), - SupportsContinuousCapture: true), - [ProfilingScenarioKind.Scrolling] = new( - ProfilingScenarioKind.Scrolling, - "Scrolling & rendering", - "Measure rendering smoothness and CPU pressure during scrolling-heavy experiences.", - [ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory], - TimeSpan.FromMinutes(3), - SupportsContinuousCapture: true), - [ProfilingScenarioKind.BackgroundWork] = new( - ProfilingScenarioKind.BackgroundWork, - "Background work", - "Profile sync, notifications, or other longer-running work that happens away from the main UI.", - [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Network, ProfilingCaptureKind.Energy], - TimeSpan.FromMinutes(10), - SupportsContinuousCapture: true), - [ProfilingScenarioKind.MemoryInvestigation] = new( - ProfilingScenarioKind.MemoryInvestigation, - "Memory investigation", - "Use memory-oriented captures to investigate leaks, spikes, and long-lived allocations.", - [ProfilingCaptureKind.Memory, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Logs], - TimeSpan.FromMinutes(15), SupportsContinuousCapture: true) }; @@ -51,96 +30,48 @@ public class ProfilingCatalogService : IProfilingCatalogService ProfilingTargetPlatform.Android, "Android", [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Emulator], - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], - [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Screenshot, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Interaction], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Mibc, ProfilingArtifactKind.Export], BuiltInScenarios.Keys.ToArray(), SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, + SupportsAttachToProcess: false, + SupportsLiveMetrics: false, SupportsSymbolication: false, - Notes: "Initial Android abstractions assume adb-backed devices and emulators."), + Notes: "Capture uses the global maui CLI with a connected Android device or running emulator."), [ProfilingTargetPlatform.iOS] = new( ProfilingTargetPlatform.iOS, - "iOS", - [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Simulator], - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], - [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Screenshot, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], + "iOS Simulator", + [ProfilingTargetKind.Simulator], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Interaction], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Mibc, ProfilingArtifactKind.Export], BuiltInScenarios.Keys.ToArray(), SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, + SupportsAttachToProcess: false, + SupportsLiveMetrics: false, SupportsSymbolication: true, - Notes: "Initial iOS abstractions cover both physical devices and simulators."), - [ProfilingTargetPlatform.MacCatalyst] = new( - ProfilingTargetPlatform.MacCatalyst, - "Mac Catalyst", - [ProfilingTargetKind.Desktop], - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], - [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], - BuiltInScenarios.Keys.ToArray(), - SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, - SupportsSymbolication: true, - Notes: "Mac Catalyst is treated as a desktop target with Apple tooling semantics."), - [ProfilingTargetPlatform.MacOS] = new( - ProfilingTargetPlatform.MacOS, - "macOS", - [ProfilingTargetKind.Desktop], - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], - [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], - BuiltInScenarios.Keys.ToArray(), - SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, - SupportsSymbolication: true, - Notes: "macOS profiling is modeled as a desktop target that can evolve beyond MAUI-specific flows."), - [ProfilingTargetPlatform.Windows] = new( - ProfilingTargetPlatform.Windows, - "Windows", - [ProfilingTargetKind.Desktop], - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory, ProfilingCaptureKind.Network, ProfilingCaptureKind.Rendering, ProfilingCaptureKind.Energy, ProfilingCaptureKind.SystemTrace, ProfilingCaptureKind.Logs], - [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Metrics, ProfilingArtifactKind.Logs, ProfilingArtifactKind.Export, ProfilingArtifactKind.Report], - BuiltInScenarios.Keys.ToArray(), - SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, - SupportsSymbolication: false, - Notes: "Windows support is modeled for desktop processes and future tooling adapters.") + Notes: "Capture uses the global maui CLI with a booted iOS simulator.") }; - private readonly IReadOnlyDictionary _capabilityProviders; - - public ProfilingCatalogService(IEnumerable capabilityProviders) - { - _capabilityProviders = capabilityProviders - .GroupBy(provider => provider.Platform) - .ToDictionary(group => group.Key, group => group.Last()); - } - - public async Task GetCatalogAsync(CancellationToken ct = default) + public Task GetCatalogAsync(CancellationToken ct = default) { - var platforms = new List(); - - foreach (var platform in Enum.GetValues()) - platforms.Add(await GetCapabilitiesAsync(platform, ct)); - - return new ProfilingCatalog(platforms, BuiltInScenarios.Values.ToArray()); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new ProfilingCatalog( + BuiltInCapabilities.Values.ToArray(), + BuiltInScenarios.Values.ToArray())); } - public async Task GetCapabilitiesAsync(ProfilingTargetPlatform platform, CancellationToken ct = default) + public Task GetCapabilitiesAsync( + ProfilingTargetPlatform platform, + CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); if (!BuiltInCapabilities.TryGetValue(platform, out var builtInCapabilities)) - throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported profiling platform."); - - if (_capabilityProviders.TryGetValue(platform, out var provider)) - { - var providerCapabilities = await provider.GetCapabilitiesAsync(ct); - if (providerCapabilities is not null) - return providerCapabilities; - } + throw new ArgumentOutOfRangeException( + nameof(platform), + platform, + "MAUI CLI profiling currently supports Android devices/emulators and iOS simulators."); - return builtInCapabilities; + return Task.FromResult(builtInCapabilities); } public ProfilingSessionDefinition CreateSessionDefinition( diff --git a/src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs b/src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs deleted file mode 100644 index 6f604369..00000000 --- a/src/MauiSherpa.Core/Services/ProfilingPrerequisitesService.cs +++ /dev/null @@ -1,492 +0,0 @@ -using System.Diagnostics; -using System.Text.RegularExpressions; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Workloads.Models; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace MauiSherpa.Core.Services; - -public class ProfilingPrerequisitesService : IProfilingPrerequisitesService -{ - private static readonly StringComparer ToolComparer = StringComparer.OrdinalIgnoreCase; - private static readonly Regex ToolListLineRegex = new( - @"^(?\S+)\s+(?\S+)\s+(?.+?)\s*$", - RegexOptions.Compiled); - private static readonly Regex VersionRegex = new(@"(?\d+(?:\.\d+)+(?:[-+][^\s]+)?)", RegexOptions.Compiled); - - private readonly IDoctorService _doctorService; - private readonly IPlatformService _platformService; - private readonly ILoggingService _loggingService; - private readonly ILogger _logger; - private readonly Func> _executeProcessAsync; - - public ProfilingPrerequisitesService( - IDoctorService doctorService, - IPlatformService platformService, - ILoggingService loggingService, - ILoggerFactory? loggerFactory = null) - : this(doctorService, platformService, loggingService, ExecuteProcessAsync, loggerFactory) - { - } - - internal ProfilingPrerequisitesService( - IDoctorService doctorService, - IPlatformService platformService, - ILoggingService loggingService, - Func> executeProcessAsync, - ILoggerFactory? loggerFactory = null) - { - _doctorService = doctorService; - _platformService = platformService; - _loggingService = loggingService; - _executeProcessAsync = executeProcessAsync; - _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - } - - public async Task GetPrerequisitesAsync( - ProfilingTargetPlatform platform, - IReadOnlyList? captureKinds = null, - string? workingDirectory = null, - CancellationToken ct = default) - { - var normalizedCaptureKinds = captureKinds? - .Distinct() - .OrderBy(kind => kind) - .ToArray() ?? Array.Empty(); - - var doctorContext = await _doctorService.GetContextAsync(workingDirectory); - var doctorReport = await _doctorService.RunDoctorAsync(doctorContext); - var dotNetExecutablePath = _doctorService.GetDotNetExecutablePath(); - var effectiveWorkingDirectory = workingDirectory ?? doctorContext.WorkingDirectory; - - var statuses = new List(); - AddHostPlatformStatus(platform, statuses); - AddDoctorDependencyStatus( - doctorReport, - statuses, - ".NET SDK", - ProfilingPrerequisiteKind.DotNetSdk, - isRequired: true, - requiredVersion: doctorContext.ResolvedSdkVersion ?? doctorContext.PinnedSdkVersion, - recommendedVersion: doctorContext.ActiveSdkVersion); - AddPlatformDependencyStatuses(platform, doctorReport, statuses); - - var discoveredTools = await DiscoverDotNetToolsAsync(dotNetExecutablePath, effectiveWorkingDirectory, ct); - AddDotNetToolStatus( - statuses, - discoveredTools, - "dotnet-trace", - "dotnet-trace", - isRequired: RequiresTraceTool(normalizedCaptureKinds), - missingMessage: "Install dotnet-trace to capture EventPipe traces for profiling workflows."); - - AddDotNetToolStatus( - statuses, - discoveredTools, - "dotnet-gcdump", - "dotnet-gcdump", - isRequired: RequiresGcDumpTool(normalizedCaptureKinds), - missingMessage: "Install dotnet-gcdump to collect memory dumps for profiling sessions."); - - AddDotNetToolStatus( - statuses, - discoveredTools, - "dotnet-dsrouter", - "dotnet-dsrouter", - isRequired: RequiresDiagnosticsRouter(platform, normalizedCaptureKinds), - missingMessage: "Install dotnet-dsrouter to bridge diagnostics traffic for mobile profiling."); - - return new ProfilingPrerequisiteReport( - new ProfilingPrerequisiteContext( - platform, - normalizedCaptureKinds, - effectiveWorkingDirectory, - dotNetExecutablePath, - doctorContext), - statuses, - DateTimeOffset.UtcNow); - } - - private void AddHostPlatformStatus( - ProfilingTargetPlatform platform, - List statuses) - { - var hostPlatform = _platformService.PlatformName; - var (supported, message) = platform switch - { - ProfilingTargetPlatform.iOS or ProfilingTargetPlatform.MacCatalyst or ProfilingTargetPlatform.MacOS - when !_platformService.IsMacOS && !_platformService.IsMacCatalyst - => (false, $"{platform} profiling requires a macOS host."), - ProfilingTargetPlatform.Windows when !_platformService.IsWindows - => (false, "Windows profiling requires a Windows host."), - _ => (true, $"Host platform '{hostPlatform}' supports {platform} profiling prerequisites.") - }; - - statuses.Add(new ProfilingPrerequisiteStatus( - "Host Platform", - ProfilingPrerequisiteKind.HostPlatform, - supported ? DependencyStatusType.Ok : DependencyStatusType.Error, - IsRequired: true, - RequiredVersion: null, - RecommendedVersion: null, - InstalledVersion: hostPlatform, - Message: message)); - } - - private void AddPlatformDependencyStatuses( - ProfilingTargetPlatform platform, - DoctorReport doctorReport, - List statuses) - { - switch (platform) - { - case ProfilingTargetPlatform.Android: - AddDoctorDependencyStatus( - doctorReport, - statuses, - "Android SDK", - ProfilingPrerequisiteKind.AndroidToolchain, - isRequired: true); - AddDoctorDependencyStatus( - doctorReport, - statuses, - "Platform Tools", - ProfilingPrerequisiteKind.AndroidToolchain, - isRequired: true, - upgradeWarningToError: true, - missingMessage: "Android platform-tools (adb) are required to profile Android targets."); - break; - - case ProfilingTargetPlatform.iOS: - AddDoctorDependencyStatus( - doctorReport, - statuses, - "Xcode", - ProfilingPrerequisiteKind.AppleToolchain, - isRequired: true); - AddDoctorDependencyStatus( - doctorReport, - statuses, - "iOS Simulators", - ProfilingPrerequisiteKind.AppleToolchain, - isRequired: false); - break; - - case ProfilingTargetPlatform.MacCatalyst: - AddDoctorDependencyStatus( - doctorReport, - statuses, - "Xcode", - ProfilingPrerequisiteKind.AppleToolchain, - isRequired: true); - break; - - case ProfilingTargetPlatform.Windows: - statuses.Add(new ProfilingPrerequisiteStatus( - "Windows Toolchain", - ProfilingPrerequisiteKind.WindowsToolchain, - DependencyStatusType.Info, - IsRequired: false, - RequiredVersion: null, - RecommendedVersion: null, - InstalledVersion: _platformService.IsWindows ? _platformService.PlatformName : null, - Message: "Windows-specific toolchain validation is not implemented yet for profiling prerequisites.")); - break; - } - } - - private static bool RequiresTraceTool(IReadOnlyList captureKinds) => - captureKinds.Count == 0 || - captureKinds.Any(kind => kind is ProfilingCaptureKind.Startup - or ProfilingCaptureKind.Cpu - or ProfilingCaptureKind.Memory - or ProfilingCaptureKind.Rendering - or ProfilingCaptureKind.Energy - or ProfilingCaptureKind.SystemTrace); - - private static bool RequiresGcDumpTool(IReadOnlyList captureKinds) => - captureKinds.Contains(ProfilingCaptureKind.Memory); - - private static bool RequiresDiagnosticsRouter( - ProfilingTargetPlatform platform, - IReadOnlyList captureKinds) => - platform is ProfilingTargetPlatform.Android or ProfilingTargetPlatform.iOS && - RequiresTraceTool(captureKinds); - - private void AddDoctorDependencyStatus( - DoctorReport doctorReport, - List statuses, - string dependencyName, - ProfilingPrerequisiteKind kind, - bool isRequired, - string? requiredVersion = null, - string? recommendedVersion = null, - bool upgradeWarningToError = false, - string? missingMessage = null) - { - var dependency = doctorReport.Dependencies - .FirstOrDefault(item => item.Name.Equals(dependencyName, StringComparison.OrdinalIgnoreCase)); - - if (dependency is null) - { - statuses.Add(new ProfilingPrerequisiteStatus( - dependencyName, - kind, - isRequired ? DependencyStatusType.Error : DependencyStatusType.Warning, - isRequired, - requiredVersion, - recommendedVersion, - InstalledVersion: null, - Message: missingMessage ?? $"{dependencyName} could not be validated from the doctor report.")); - return; - } - - var status = dependency.Status; - if (upgradeWarningToError && status == DependencyStatusType.Warning) - status = DependencyStatusType.Error; - - var message = dependency.Message; - if (upgradeWarningToError && dependency.Status == DependencyStatusType.Warning && !string.IsNullOrWhiteSpace(message)) - message = $"{message} This is required for profiling readiness."; - - statuses.Add(new ProfilingPrerequisiteStatus( - dependency.Name, - kind, - status, - isRequired, - requiredVersion ?? dependency.RequiredVersion, - recommendedVersion ?? dependency.RecommendedVersion, - dependency.InstalledVersion, - message, - IsFixable: dependency.IsFixable, - FixAction: dependency.FixAction)); - } - - private void AddDotNetToolStatus( - List statuses, - IReadOnlyDictionary discoveredTools, - string commandName, - string packageId, - bool isRequired, - string missingMessage) - { - if (!isRequired && !discoveredTools.ContainsKey(commandName)) - return; - - if (!discoveredTools.TryGetValue(commandName, out var tool)) - { - statuses.Add(new ProfilingPrerequisiteStatus( - commandName, - ProfilingPrerequisiteKind.DotNetTool, - isRequired ? DependencyStatusType.Error : DependencyStatusType.Warning, - isRequired, - RequiredVersion: null, - RecommendedVersion: null, - InstalledVersion: null, - Message: missingMessage, - DiscoveredBy: null, - ExecutablePath: null, - IsFixable: true, - FixAction: $"install-dotnet-tool:{packageId}", - SuggestedCommand: $"dotnet tool install --global {packageId}")); - return; - } - - var message = $"{commandName} {tool.Version} discovered via {tool.Source}."; - - statuses.Add(new ProfilingPrerequisiteStatus( - commandName, - ProfilingPrerequisiteKind.DotNetTool, - DependencyStatusType.Ok, - isRequired, - RequiredVersion: null, - RecommendedVersion: null, - InstalledVersion: tool.Version, - Message: message, - DiscoveredBy: tool.Source, - ExecutablePath: tool.ExecutablePath, - IsFixable: false, - FixAction: null, - SuggestedCommand: null)); - } - - private async Task> DiscoverDotNetToolsAsync( - string dotNetExecutablePath, - string? workingDirectory, - CancellationToken ct) - { - var discoveredTools = new Dictionary(ToolComparer); - - if (!string.IsNullOrWhiteSpace(workingDirectory)) - { - foreach (var tool in await TryListDotNetToolsAsync(dotNetExecutablePath, ["tool", "list", "--local"], "local-manifest", workingDirectory, ct)) - discoveredTools[tool.Command] = tool; - } - - foreach (var tool in await TryListDotNetToolsAsync(dotNetExecutablePath, ["tool", "list", "--global"], "global-tool", null, ct)) - discoveredTools[tool.Command] = tool; - - foreach (var command in new[] { "dotnet-trace", "dotnet-gcdump", "dotnet-dsrouter" }) - { - if (discoveredTools.ContainsKey(command)) - continue; - - var shimPath = ResolveGlobalToolShim(command); - if (shimPath is null) - continue; - - var result = await _executeProcessAsync( - new ProcessRequest(shimPath, ["--version"], workingDirectory), - ct); - - if (!result.Success) - continue; - - var version = TryExtractVersion(result.Output) ?? TryExtractVersion(result.Error); - if (version is null) - continue; - - discoveredTools[command] = new DiscoveredDotNetTool(command, version, "global-shim", shimPath); - } - - return discoveredTools; - } - - private async Task> TryListDotNetToolsAsync( - string dotNetExecutablePath, - string[] arguments, - string source, - string? workingDirectory, - CancellationToken ct) - { - try - { - var result = await _executeProcessAsync( - new ProcessRequest(dotNetExecutablePath, arguments, workingDirectory), - ct); - - if (!result.Success) - { - _logger.LogDebug("Failed to list {Source} dotnet tools: {Error}", source, result.Error); - return Array.Empty(); - } - - return ParseDotNetToolList(result.Output, source); - } - catch (Exception ex) - { - _loggingService.LogDebug($"Failed to discover {source} dotnet tools: {ex.Message}"); - return Array.Empty(); - } - } - - internal static IReadOnlyList ParseDotNetToolList(string output, string source) - { - var results = new List(); - var lines = output - .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .ToArray(); - - var separatorIndex = Array.FindIndex(lines, line => line.StartsWith("---", StringComparison.Ordinal)); - if (separatorIndex < 0) - return results; - - foreach (var line in lines[(separatorIndex + 1)..]) - { - var match = ToolListLineRegex.Match(line); - if (!match.Success) - continue; - - var version = match.Groups["version"].Value; - var commands = match.Groups["commands"].Value - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var command in commands) - results.Add(new DiscoveredDotNetTool(command, version, source, ResolveGlobalToolShim(command))); - } - - return results; - } - - private static int? GetSdkMajorVersion(string? version) - { - if (string.IsNullOrWhiteSpace(version)) - return null; - - if (SdkVersion.TryParse(version, out var sdkVersion) && sdkVersion is not null) - return sdkVersion.Major; - - var match = Regex.Match(version, @"^(?\d+)"); - return match.Success && int.TryParse(match.Groups["major"].Value, out var major) ? major : null; - } - - private static string? TryExtractVersion(string? output) - { - if (string.IsNullOrWhiteSpace(output)) - return null; - - var match = VersionRegex.Match(output); - return match.Success ? match.Groups["version"].Value : null; - } - - private static string? ResolveGlobalToolShim(string command) - { - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (string.IsNullOrWhiteSpace(userProfile)) - return null; - - var executableName = OperatingSystem.IsWindows() ? $"{command}.exe" : command; - var candidate = Path.Combine(userProfile, ".dotnet", "tools", executableName); - return File.Exists(candidate) ? candidate : null; - } - - private static async Task ExecuteProcessAsync(ProcessRequest request, CancellationToken ct) - { - try - { - var startInfo = new ProcessStartInfo - { - FileName = request.Command, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - WorkingDirectory = request.WorkingDirectory ?? Environment.CurrentDirectory - }; - - foreach (var arg in request.Arguments) - startInfo.ArgumentList.Add(arg); - - if (request.Environment is not null) - { - foreach (var (key, value) in request.Environment) - startInfo.Environment[key] = value; - } - - using var process = new Process { StartInfo = startInfo }; - process.Start(); - var outputTask = process.StandardOutput.ReadToEndAsync(ct); - var errorTask = process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - var output = await outputTask; - var error = await errorTask; - var finalState = process.ExitCode == 0 ? ProcessState.Completed : ProcessState.Failed; - - return new ProcessResult(process.ExitCode, output, error, TimeSpan.Zero, finalState); - } - catch (Exception ex) - { - return new ProcessResult(-1, string.Empty, ex.Message, TimeSpan.Zero, ProcessState.Failed); - } - } - - internal sealed record DiscoveredDotNetTool( - string Command, - string Version, - string Source, - string? ExecutablePath); -} diff --git a/src/MauiSherpa.Core/Services/ProfilingSessionStorageService.cs b/src/MauiSherpa.Core/Services/ProfilingSessionStorageService.cs new file mode 100644 index 00000000..f92ded85 --- /dev/null +++ b/src/MauiSherpa.Core/Services/ProfilingSessionStorageService.cs @@ -0,0 +1,529 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Services; + +/// +/// Manages persistent profiling sessions stored under AppDataPath/profiling/. +/// Each session is a folder containing session.json + artifact files. +/// +public class ProfilingSessionStorageService : IProfilingSessionStorageService +{ + private readonly string _profilingRoot; + private readonly ILoggingService _logger; + private readonly IProfilingArtifactLibraryService _artifactLibrary; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + private const string ManifestFileName = "session.json"; + + public ProfilingSessionStorageService( + ILoggingService logger, + IProfilingArtifactLibraryService artifactLibrary) + : this( + logger, + artifactLibrary, + Path.Combine(AppDataPath.GetAppDataDirectory(), "profiling")) + { + } + + internal ProfilingSessionStorageService( + ILoggingService logger, + IProfilingArtifactLibraryService artifactLibrary, + string profilingRoot) + { + _logger = logger; + _artifactLibrary = artifactLibrary; + _profilingRoot = Path.GetFullPath(profilingRoot); + Directory.CreateDirectory(_profilingRoot); + } + + public async Task> GetSessionsAsync(CancellationToken ct = default) + { + var sessions = new List(); + + if (!Directory.Exists(_profilingRoot)) + return sessions; + + foreach (var dir in Directory.GetDirectories(_profilingRoot)) + { + ct.ThrowIfCancellationRequested(); + var manifestPath = Path.Combine(dir, ManifestFileName); + if (!File.Exists(manifestPath)) + continue; + + try + { + var manifest = await ReadManifestAsync(manifestPath, ct); + if (manifest is not null) + { + manifest.DirectoryPath = dir; + sessions.Add(manifest); + } + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to read session manifest at {manifestPath}: {ex.Message}"); + } + } + + // Most recent first + sessions.Sort((a, b) => b.CreatedAt.CompareTo(a.CreatedAt)); + return sessions; + } + + public async Task GetSessionAsync(string sessionId, CancellationToken ct = default) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + var manifestPath = Path.Combine(dir, ManifestFileName); + + if (!File.Exists(manifestPath)) + return null; + + var manifest = await ReadManifestAsync(manifestPath, ct); + if (manifest is not null) + manifest.DirectoryPath = dir; + return manifest; + } + + public async Task SaveSessionAsync(ProfilingSessionManifest manifest, CancellationToken ct = default) + { + var dir = GetSessionDirectoryPath(manifest.Id); + var manifestPath = Path.Combine(dir, ManifestFileName); + + // Update artifact sizes from disk + foreach (var artifact in manifest.Artifacts) + { + var artifactPath = Path.Combine(dir, artifact.FileName); + if (File.Exists(artifactPath)) + { + var info = new FileInfo(artifactPath); + // Use reflection-free approach: create new record with updated size + if (artifact.SizeBytes is null || artifact.SizeBytes == 0) + { + var idx = manifest.Artifacts.IndexOf(artifact); + if (idx >= 0) + { + manifest.Artifacts[idx] = artifact with { SizeBytes = info.Length }; + } + } + } + } + + manifest.DirectoryPath = dir; + + var json = JsonSerializer.Serialize(manifest, JsonOptions); + var pendingManifestPath = $"{manifestPath}.pending"; + await File.WriteAllTextAsync(pendingManifestPath, json, ct); + File.Move(pendingManifestPath, manifestPath, overwrite: true); + + await SyncArtifactLibraryAsync(manifest, dir, ct); + + _logger.LogInformation($"Session manifest saved: {manifest.Id}"); + } + + public async Task DeleteSessionAsync(string sessionId, CancellationToken ct = default) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + + var libraryEntries = await _artifactLibrary.GetArtifactsAsync( + new ProfilingArtifactLibraryQuery(SessionId: sessionId), + ct); + foreach (var entry in libraryEntries) + await _artifactLibrary.DeleteArtifactAsync(entry.Metadata.Id, deleteFile: false, ct); + + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + _logger.LogInformation($"Session deleted: {sessionId}"); + } + + } + + public async Task SaveMauiProfileSessionAsync( + string sessionId, + MauiProfileRequest request, + MauiProfileResult result, + string? cliVersion = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(result); + + var sessionDirectory = GetSessionDirectoryPath(sessionId); + var sourcePaths = new[] { result.OutputPath, result.RawTracePath } + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => Path.GetFullPath(x!)) + .Distinct(GetPathComparer()) + .ToArray(); + + if (sourcePaths.Length == 0) + throw new InvalidOperationException("The MAUI CLI did not return an artifact path."); + + var artifacts = new List(); + foreach (var sourcePath in sourcePaths) + { + ct.ThrowIfCancellationRequested(); + var managedPath = await EnsureManagedArtifactAsync(sourcePath, sessionDirectory, ct); + var info = new FileInfo(managedPath); + artifacts.Add(new ProfilingSessionArtifact + { + FileName = info.Name, + Kind = ProfilingArtifactClassifier.Classify(info.Name), + SizeBytes = info.Length, + DisplayName = ProfilingArtifactClassifier.GetDisplayName(info.Name) + }); + } + + RemoveIntermediateFiles(sessionDirectory); + + var startedAt = result.StartedAtUtc ?? DateTimeOffset.UtcNow; var completedAt = result.CompletedAtUtc ?? DateTimeOffset.UtcNow; + var framework = string.IsNullOrWhiteSpace(result.Framework) ? null : result.Framework; + var format = ParseOutputFormat(result.Format) ?? request.Format; + var rawTraceFileName = result.RawTracePath is null + ? null + : artifacts.FirstOrDefault(x => + x.FileName.Equals(Path.GetFileName(result.RawTracePath), GetPathComparison())) + ?.FileName; + var targetKind = request.Platform == ProfilingTargetPlatform.iOS + ? ProfilingTargetKind.Simulator + : request.IsEmulator + ? ProfilingTargetKind.Emulator + : ProfilingTargetKind.PhysicalDevice; + + var manifest = new ProfilingSessionManifest + { + SchemaVersion = 2, + Id = sessionId, + Name = $"{result.ProjectName} — {GetModeDisplayName(request.Mode)} on {result.DeviceName}", + Status = ProfilingSessionStatus.Completed, + CreatedAt = startedAt, + CompletedAt = completedAt, + Target = new ProfilingSessionTarget + { + Platform = request.Platform, + Kind = targetKind, + Identifier = result.DeviceId, + DisplayName = result.DeviceName + }, + Project = new ProfilingSessionProject + { + Path = result.ProjectPath, + Name = result.ProjectName, + Configuration = result.Configuration, + TargetFramework = framework + }, + CaptureKinds = request.Mode == MauiProfileMode.Startup + ? [ProfilingCaptureKind.Startup] + : [ProfilingCaptureKind.Interaction], + Options = new ProfilingSessionOptions + { + LaunchMode = ProfilingCaptureLaunchMode.Launch, + DiagnosticPort = result.DiagnosticPort ?? 9000, + SuspendAtStartup = request.Mode == MauiProfileMode.Startup, + Scenario = request.Mode == MauiProfileMode.Startup + ? ProfilingScenarioKind.Launch + : ProfilingScenarioKind.Interaction + }, + Pipeline = new ProfilingSessionPipelineSummary + { + Success = true, + Duration = completedAt - startedAt, + Steps = [] + }, + MauiProfile = new MauiProfileSessionDetails + { + Mode = request.Mode, + Format = format, + CliVersion = cliVersion, + Framework = framework, + RawTraceFileName = rawTraceFileName, + UsedStoppingEvent = result.UsedStoppingEvent, + StartedAtUtc = result.StartedAtUtc, + CompletedAtUtc = result.CompletedAtUtc + }, + Artifacts = artifacts + }; + + await SaveSessionAsync(manifest, ct); + return manifest; + } + + public async Task ImportArtifactAsync( + string artifactPath, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(artifactPath); + var sourcePath = Path.GetFullPath(artifactPath); + if (!File.Exists(sourcePath)) + throw new FileNotFoundException("The profiling artifact could not be found.", sourcePath); + if (!ProfilingArtifactClassifier.IsSupported(sourcePath)) + throw new NotSupportedException($"'{Path.GetExtension(sourcePath)}' is not a supported profiling artifact."); + + var sessionName = ProfilingArtifactClassifier.GetBaseName(sourcePath); + var sessionId = GenerateSessionId(sessionName); + var sessionDirectory = GetSessionDirectoryPath(sessionId); + var managedPath = await EnsureManagedArtifactAsync(sourcePath, sessionDirectory, ct); + var info = new FileInfo(managedPath); + var now = DateTimeOffset.UtcNow; + + var manifest = new ProfilingSessionManifest + { + SchemaVersion = 2, + Id = sessionId, + Name = $"{sessionName} — Imported", + Status = ProfilingSessionStatus.Completed, + CreatedAt = now, + CompletedAt = now, + Target = new ProfilingSessionTarget + { + Platform = ProfilingTargetPlatform.Unknown, + Kind = ProfilingTargetKind.Unknown, + Identifier = "imported", + DisplayName = "Imported artifact" + }, + CaptureKinds = [], + Options = new ProfilingSessionOptions + { + LaunchMode = ProfilingCaptureLaunchMode.Attach, + Scenario = ProfilingScenarioKind.Interaction + }, + Artifacts = + [ + new ProfilingSessionArtifact + { + FileName = info.Name, + Kind = ProfilingArtifactClassifier.Classify(info.Name), + SizeBytes = info.Length, + DisplayName = ProfilingArtifactClassifier.GetDisplayName(info.Name) + } + ] + }; + + await SaveSessionAsync(manifest, ct); + return manifest; + } + + public string GetSessionDirectoryPath(string sessionId) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + Directory.CreateDirectory(dir); + return dir; + } + + public string GenerateSessionId(string? projectName = null) + { + var datePart = DateTime.Now.ToString("yyyy-MM-dd"); + var namePart = SanitizePath(projectName ?? "session"); + var baseName = $"{datePart}_{namePart}"; + + // Find next available run number + var runNumber = 1; + while (Directory.Exists(Path.Combine(_profilingRoot, $"{baseName}_{runNumber}"))) + { + runNumber++; + } + + return $"{baseName}_{runNumber}"; + } + + public async Task ExportSessionAsync(string sessionId, string outputZipPath, CancellationToken ct = default) + { + var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); + + if (!Directory.Exists(dir)) + throw new DirectoryNotFoundException($"Session directory not found: {dir}"); + + // Delete existing zip if present (save dialog may have created empty file) + if (File.Exists(outputZipPath)) + File.Delete(outputZipPath); + + await Task.Run(() => ZipFile.CreateFromDirectory(dir, outputZipPath), ct); + _logger.LogInformation($"Session exported: {sessionId} → {outputZipPath}"); + } + + public async Task ImportSessionAsync(string zipPath, CancellationToken ct = default) + { + if (!File.Exists(zipPath)) + return null; + + // Extract to a temp directory first to read manifest + var tempDir = Path.Combine(Path.GetTempPath(), $"sherpa-import-{Guid.NewGuid():N}"); + try + { + await Task.Run(() => ZipFile.ExtractToDirectory(zipPath, tempDir), ct); + + var manifestPath = Path.Combine(tempDir, ManifestFileName); + if (!File.Exists(manifestPath)) + { + _logger.LogWarning($"Imported zip has no {ManifestFileName}"); + return null; + } + + var manifest = await ReadManifestAsync(manifestPath, ct); + if (manifest is null) + return null; + + // Move to managed location (use a new ID if collision) + var targetId = manifest.Id; + var targetDir = Path.Combine(_profilingRoot, SanitizePath(targetId)); + if (Directory.Exists(targetDir)) + { + // Generate new ID to avoid collision + targetId = GenerateSessionId(manifest.Name); + targetDir = Path.Combine(_profilingRoot, SanitizePath(targetId)); + manifest = manifest with { Id = targetId }; + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); + + // Move the extracted folder to the managed location + if (Directory.Exists(targetDir)) + Directory.Delete(targetDir, true); + Directory.Move(tempDir, targetDir); + + manifest.DirectoryPath = targetDir; + await SaveSessionAsync(manifest, ct); + _logger.LogInformation($"Session imported: {manifest.Id} from {zipPath}"); + return manifest; + } + catch (Exception ex) + { + _logger.LogError($"Failed to import session from {zipPath}: {ex.Message}", ex); + return null; + } + finally + { + // Clean up temp directory if it still exists + if (Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, true); } + catch { /* best effort */ } + } + } + } + + private static async Task ReadManifestAsync(string path, CancellationToken ct) + { + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); + } + + /// + /// The CLI leaves a TraceEvent index (.etlx) beside MIBC output. It is derived from + /// the raw trace, is often larger than the trace itself, and nothing in Sherpa reads it. + /// + private void RemoveIntermediateFiles(string sessionDirectory) + { + try + { + if (!Directory.Exists(sessionDirectory)) + return; + + foreach (var file in Directory.EnumerateFiles(sessionDirectory, "*.etlx")) + File.Delete(file); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning( + $"Could not remove intermediate profiling files in '{sessionDirectory}': {ex.Message}"); + } + } + + private async Task EnsureManagedArtifactAsync( + string sourcePath, + string sessionDirectory, + CancellationToken ct) + { + var sourceInfo = new FileInfo(sourcePath); + if (!sourceInfo.Exists || sourceInfo.Length == 0) + throw new InvalidDataException($"Profiling artifact '{sourcePath}' is missing or empty."); + + var destinationPath = Path.Combine(sessionDirectory, sourceInfo.Name); + if (!string.Equals( + Path.GetFullPath(sourcePath), + Path.GetFullPath(destinationPath), + GetPathComparison())) + { + await using var source = File.OpenRead(sourcePath); + await using var destination = File.Create(destinationPath); + await source.CopyToAsync(destination, ct); + } + + return destinationPath; + } + + private async Task SyncArtifactLibraryAsync( + ProfilingSessionManifest manifest, + string sessionDirectory, + CancellationToken ct) + { + foreach (var artifact in manifest.Artifacts) + { + var artifactPath = Path.Combine(sessionDirectory, artifact.FileName); + if (!File.Exists(artifactPath)) + continue; + + var metadata = new ProfilingArtifactMetadata( + Id: $"{manifest.Id}:{artifact.FileName}", + SessionId: manifest.Id, + Kind: artifact.Kind, + DisplayName: artifact.DisplayName ?? ProfilingArtifactClassifier.GetDisplayName(artifact.FileName), + FileName: artifact.FileName, + RelativePath: artifactPath, + ContentType: ProfilingArtifactClassifier.GetContentType(artifact.FileName), + CreatedAt: manifest.CompletedAt ?? manifest.CreatedAt, + SizeBytes: artifact.SizeBytes); + + await _artifactLibrary.SaveArtifactAsync( + new ProfilingArtifactLibrarySaveRequest( + metadata, + ArtifactPath: artifactPath, + CopyToLibrary: false), + ct); + } + } + + private static string GetModeDisplayName(MauiProfileMode mode) => mode switch + { + MauiProfileMode.Startup => "Startup", + MauiProfileMode.Interaction => "Interaction", + _ => mode.ToString() + }; + + private static MauiProfileOutputFormat? ParseOutputFormat(string? format) => format?.Trim().ToLowerInvariant() switch + { + "nettrace" => MauiProfileOutputFormat.NetTrace, + "speedscope" => MauiProfileOutputFormat.Speedscope, + "mibc" => MauiProfileOutputFormat.Mibc, + _ => null + }; + + private static StringComparer GetPathComparer() => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static StringComparison GetPathComparison() => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private static string SanitizePath(string input) + { + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new char[input.Length]; + for (int i = 0; i < input.Length; i++) + { + sanitized[i] = Array.IndexOf(invalid, input[i]) >= 0 ? '_' : input[i]; + } + return new string(sanitized).Trim('.'); + } +} diff --git a/src/MauiSherpa.MacOS/BlazorContentPage.cs b/src/MauiSherpa.MacOS/BlazorContentPage.cs index be5c5d53..0e480cc0 100644 --- a/src/MauiSherpa.MacOS/BlazorContentPage.cs +++ b/src/MauiSherpa.MacOS/BlazorContentPage.cs @@ -498,7 +498,17 @@ void UpdateToolbarVisibility() _searchItem.Placeholder = _toolbarService.SearchPlaceholder ?? ""; } - // Update enabled state for action items via native API + // Update enabled state and labels for action items via native API. + // The shared superset assigns each item a default label, so adopt the + // label the current page registered — MAUI's toolbar handler does not + // propagate ToolbarItem.Text changes after the item is created. + var pageLabels = new Dictionary(); + foreach (var action in _toolbarService.CurrentItems) + { + if (!string.IsNullOrWhiteSpace(action.Label)) + pageLabels[action.Id] = action.Label; + } + var enabledSelector = new ObjCRuntime.Selector("setEnabled:"); foreach (var nsItem in toolbar.Items) { @@ -510,6 +520,13 @@ void UpdateToolbarVisibility() var actionId = _actionItemMap.Keys.ElementAt(idx); if (nsItem.RespondsToSelector(enabledSelector)) _objc_msgSend_bool(nsItem.Handle, enabledSelector.Handle, _toolbarService.IsItemEnabled(actionId)); + + if (pageLabels.TryGetValue(actionId, out var label)) + { + nsItem.Label = label; + nsItem.PaletteLabel = label; + nsItem.ToolTip = label; + } } } } diff --git a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs index 63d0ac6f..291df562 100644 --- a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs +++ b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs @@ -106,7 +106,8 @@ public static MauiApp CreateMauiApp() // Process execution services builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -131,8 +132,6 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -277,8 +276,6 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); - builder.Services.AddSingletonAsImplementedInterfaces(); - builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); #if DEBUG diff --git a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj index 330c4e3a..022c2f51 100644 --- a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj +++ b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj @@ -27,16 +27,13 @@ - - - - + diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index f4c5e413..1e277d00 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -122,6 +122,8 @@ public static MauiApp CreateMauiApp() // Process execution services builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); @@ -142,11 +144,8 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -297,8 +296,6 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); - builder.Services.AddSingletonAsImplementedInterfaces(); - builder.Services.AddSingletonAsImplementedInterfaces(); builder.Services.AddSingletonAsImplementedInterfaces(); #if DEBUG diff --git a/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs b/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs index 418d6b50..b820ce6e 100644 --- a/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs +++ b/src/MauiSherpa/Pages/Forms/HybridFormBridge.cs @@ -25,6 +25,9 @@ public class HybridFormBridge /// Fired by the Blazor component when wizard button state changes. public event Action? WizardStateChanged; + /// Fired by the Blazor component when the native form actions change. + public event Action? ActionStateChanged; + /// Current form validity, set by the Blazor component. public bool IsValid { get; private set; } @@ -42,6 +45,13 @@ public class HybridFormBridge public bool IsSubmitting { get; private set; } public string? SubmitText { get; private set; } + // Dynamic form action state — used by long-running forms that remain open after submit. + public bool HasActionState { get; private set; } + public bool ActionEnabled { get; private set; } + public bool ActionBusy { get; private set; } + public string? ActionText { get; private set; } + public string? SecondaryActionText { get; private set; } + /// When true, native Cancel does not close the modal (Blazor handles it). public bool PreventClose { get; set; } @@ -67,6 +77,24 @@ public void SetWizardState(bool showBack, bool showNext, bool showSubmit, bool c WizardStateChanged?.Invoke(); } + /// + /// Updates the native primary and secondary actions for a form that remains open + /// while a long-running operation changes state. + /// + public void SetActionState( + bool enabled, + bool busy = false, + string? actionText = null, + string? secondaryActionText = null) + { + HasActionState = true; + ActionEnabled = enabled; + ActionBusy = busy; + ActionText = actionText; + SecondaryActionText = secondaryActionText; + ActionStateChanged?.Invoke(); + } + /// Fired by the MAUI page when a custom action button is clicked (e.g. "save", "reset"). public event Func? ActionRequested; diff --git a/src/MauiSherpa/Pages/Forms/HybridFormPage.cs b/src/MauiSherpa/Pages/Forms/HybridFormPage.cs index dc5b878c..44af01e2 100644 --- a/src/MauiSherpa/Pages/Forms/HybridFormPage.cs +++ b/src/MauiSherpa/Pages/Forms/HybridFormPage.cs @@ -25,8 +25,10 @@ public abstract class HybridFormPage : ContentPage, IFormPage, private readonly HybridFormBridge _bridge = new(); private readonly HybridFormBridgeHolder _bridgeHolder; private Button? _submitButton; + private Button? _cancelButton; private ActivityIndicator? _submittingIndicator; private bool _isSubmitting; + private bool _isClosed; private View? _webView; /// Page title shown in the native header. @@ -35,6 +37,9 @@ public abstract class HybridFormPage : ContentPage, IFormPage, /// Submit button text (e.g. "Save", "Export", "Create"). protected virtual string SubmitButtonText => "Save"; + /// Height of the native footer actions. + protected virtual double ActionButtonHeight => 30; + /// Blazor route for the form content (e.g. "/modal/edit-secret"). protected abstract string BlazorRoute { get; } @@ -82,9 +87,11 @@ private void BuildPage() _bridge.ValidationChanged += () => Dispatcher.Dispatch(() => { - if (_submitButton != null) + if (_submitButton != null && !_bridge.HasActionState) _submitButton.IsEnabled = _bridge.IsValid && !_isSubmitting; }); + _bridge.ActionStateChanged += OnActionStateChanged; + _bridge.CloseRequested += OnCloseRequested; // Title var titleLabel = new Label @@ -138,7 +145,7 @@ private void BuildPage() footerSeparator.SetDynamicResource(BoxView.ColorProperty, FormTheme.Separator); - var cancelButton = new Button + _cancelButton = new Button { Text = "Cancel", FontSize = 13, @@ -146,10 +153,10 @@ private void BuildPage() BorderWidth = 0, CornerRadius = 5, Padding = new Thickness(14, 4), - HeightRequest = 30, + HeightRequest = ActionButtonHeight, }; - cancelButton.SetDynamicResource(Button.TextColorProperty, FormTheme.AccentPrimary); - cancelButton.Clicked += OnCancelClicked; + _cancelButton.SetDynamicResource(Button.TextColorProperty, FormTheme.AccentPrimary); + _cancelButton.Clicked += OnCancelClicked; _submittingIndicator = new ActivityIndicator { @@ -169,7 +176,7 @@ private void BuildPage() TextColor = Colors.White, CornerRadius = 5, Padding = new Thickness(14, 4), - HeightRequest = 30, + HeightRequest = ActionButtonHeight, IsEnabled = false, }; _submitButton.SetDynamicResource(Button.BackgroundColorProperty, FormTheme.AccentPrimary); @@ -180,7 +187,7 @@ private void BuildPage() Spacing = 12, HorizontalOptions = LayoutOptions.End, Margin = new Thickness(28, 12, 28, 24), - Children = { cancelButton, _submittingIndicator, _submitButton }, + Children = { _cancelButton, _submittingIndicator, _submitButton }, }; var grid = new Grid @@ -212,6 +219,7 @@ private void BuildPage() grid.Children.Add(footerLayout); Content = grid; + ApplyActionState(); } private async void OnSubmitClicked(object? sender, EventArgs e) @@ -226,17 +234,30 @@ private async void OnSubmitClicked(object? sender, EventArgs e) try { await _bridge.RequestSubmitAsync(); + if (_bridge.PreventSubmitClose) + { + _isSubmitting = false; + ApplyActionState(); + return; + } + var result = (TResult?)_bridge.Result; - _bridgeHolder.Pop(); - _tcs.TrySetResult(result); + CloseWithResult(result); } catch (Exception ex) { _isSubmitting = false; - _submitButton.Text = SubmitButtonText; - _submitButton.IsEnabled = _bridge.IsValid; - _submittingIndicator.IsRunning = false; - _submittingIndicator.IsVisible = false; + if (_bridge.HasActionState) + { + ApplyActionState(); + } + else + { + _submitButton.Text = SubmitButtonText; + _submitButton.IsEnabled = _bridge.IsValid; + _submittingIndicator.IsRunning = false; + _submittingIndicator.IsVisible = false; + } await DisplayAlert("Error", ex.Message, "OK"); } @@ -245,8 +266,63 @@ private async void OnSubmitClicked(object? sender, EventArgs e) private void OnCancelClicked(object? sender, EventArgs e) { _bridge.RequestCancel(); + if (_bridge.PreventClose) + { + ApplyActionState(); + return; + } + + CloseWithResult(default); + } + + private void OnActionStateChanged() + { + if (Dispatcher.IsDispatchRequired) + Dispatcher.Dispatch(ApplyActionState); + else + ApplyActionState(); + } + + private void ApplyActionState() + { + if (!_bridge.HasActionState) + return; + + if (_submitButton != null) + { + _submitButton.Text = _bridge.ActionText ?? SubmitButtonText; + _submitButton.IsEnabled = _bridge.ActionEnabled && !_bridge.ActionBusy && !_isSubmitting; + } + + if (_cancelButton != null) + _cancelButton.Text = _bridge.SecondaryActionText ?? "Cancel"; + + if (_submittingIndicator != null) + { + _submittingIndicator.IsRunning = _bridge.ActionBusy; + _submittingIndicator.IsVisible = _bridge.ActionBusy; + } + } + + private void OnCloseRequested() + { + Dispatcher.Dispatch(() => + { + var result = (TResult?)_bridge.Result; + CloseWithResult(result); + }); + } + + private void CloseWithResult(TResult? result) + { + if (_isClosed) + return; + + _isClosed = true; + _bridge.ActionStateChanged -= OnActionStateChanged; + _bridge.CloseRequested -= OnCloseRequested; _bridgeHolder.Pop(); - _tcs.TrySetResult(default); + _tcs.TrySetResult(result); } private void OnBlazorReady(double contentHeight) diff --git a/src/MauiSherpa/Pages/Modals/MauiCliDetailsModal.razor b/src/MauiSherpa/Pages/Modals/MauiCliDetailsModal.razor new file mode 100644 index 00000000..f037e452 --- /dev/null +++ b/src/MauiSherpa/Pages/Modals/MauiCliDetailsModal.razor @@ -0,0 +1,280 @@ +@page "/modal/maui-cli-details" +@using MauiSherpa.Core.Models.Profiling +@using MauiSherpa.Pages.Forms +@inject ModalParameterService ModalParams + +@if (session is null) +{ +
+ + MAUI CLI details are unavailable. +
+} +else +{ +
+
+ +
+ @StatusTitle + @StatusDescription +
+
+ +
+
+
Installed version
+
@ValueOrFallback(InstalledVersion)
+
+
+
Latest on NuGet
+
@ValueOrFallback(session.UpdateInfo?.LatestVersion)
+
+ @if (!string.IsNullOrWhiteSpace(session.Status.ExecutablePath)) + { +
+
Binary path
+
@session.Status.ExecutablePath
+
+ } +
+ + @if (!string.IsNullOrWhiteSpace(session.UpdateInfo?.Message) && !UpdateAvailable) + { +

@session.UpdateInfo!.Message

+ } + +
+ @if (session.Status.State == MauiCliToolState.Missing) + { + + } + else + { + + + } +
+
+} + + + +@code { + private MauiCliDetailsSession? session; + + private bool UpdateAvailable => session?.UpdateInfo?.UpdateAvailable == true; + + private string? InstalledVersion => + session?.UpdateInfo?.InstalledVersion ?? session?.Status.Version; + + private string StatusClass => session?.Status.State switch + { + MauiCliToolState.Missing => "missing", + MauiCliToolState.UpdateRequired => "missing", + _ => UpdateAvailable ? "update" : "ready" + }; + + private string StatusIcon => session?.Status.State switch + { + MauiCliToolState.Missing => "fa-circle-xmark", + MauiCliToolState.UpdateRequired => "fa-circle-xmark", + _ => UpdateAvailable ? "fa-circle-up" : "fa-circle-check" + }; + + private string StatusTitle => session?.Status.State switch + { + MauiCliToolState.Missing => "Not installed", + MauiCliToolState.UpdateRequired => "Update required", + _ => UpdateAvailable ? "Update available" : "Ready" + }; + + private string StatusDescription + { + get + { + if (session is null) + return string.Empty; + + return session.Status.State switch + { + MauiCliToolState.Missing => + "Sherpa uses the Microsoft.Maui.Cli global tool to build, launch, and profile your app.", + MauiCliToolState.UpdateRequired => + session.Status.Message ?? "Update Microsoft.Maui.Cli to a version that supports profiling.", + _ when UpdateAvailable => + $"Version {session.UpdateInfo!.LatestVersion} is available. Updating can pick up profiling fixes.", + _ => "The MAUI CLI is ready to capture profiles." + }; + } + } + + protected override void OnInitialized() + { + session = ModalParams.Get("Session"); + } + + private void SelectAction(MauiCliDetailsAction action) => + session?.RequestAction(action); + + private static string ValueOrFallback(string? value) => + string.IsNullOrWhiteSpace(value) ? "Unknown" : value; +} diff --git a/src/MauiSherpa/Pages/Modals/MauiCliDetailsPage.cs b/src/MauiSherpa/Pages/Modals/MauiCliDetailsPage.cs new file mode 100644 index 00000000..7f5ae88c --- /dev/null +++ b/src/MauiSherpa/Pages/Modals/MauiCliDetailsPage.cs @@ -0,0 +1,66 @@ +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Pages.Forms; +#if MACOSAPP +using Microsoft.Maui.Platforms.MacOS.Platform; +#endif +#if LINUXGTK +using Microsoft.Maui.Platforms.Linux.Gtk4.Platform; +#endif + +namespace MauiSherpa.Pages.Modals; + +public enum MauiCliDetailsAction +{ + None, + Install, + Update, + Recheck +} + +public sealed class MauiCliDetailsSession +{ + public required MauiCliToolStatus Status { get; init; } + public MauiCliToolUpdateInfo? UpdateInfo { get; init; } + public required Action RequestAction { get; init; } +} + +public sealed class MauiCliDetailsPage : HybridViewPage +{ + protected override string FormTitle => "MAUI CLI"; + protected override string BlazorRoute => "/modal/maui-cli-details"; + + public MauiCliDetailsAction SelectedAction { get; private set; } + + public MauiCliDetailsPage( + ModalParameterService modalParams, + MauiCliToolStatus status, + MauiCliToolUpdateInfo? updateInfo) + { + var session = new MauiCliDetailsSession + { + Status = status, + UpdateInfo = updateInfo, + RequestAction = SelectAction + }; + + modalParams.Clear(); + modalParams.Set("Session", session); + +#if MACOSAPP + MacOSPage.SetModalSheetWidth(this, 620); + MacOSPage.SetModalSheetHeight(this, 380); +#elif LINUXGTK + GtkPage.SetModalWidth(this, 620); + GtkPage.SetModalHeight(this, 380); +#endif + } + + private void SelectAction(MauiCliDetailsAction action) + { + if (action == MauiCliDetailsAction.None) + return; + + SelectedAction = action; + Dispatcher.Dispatch(CompleteClose); + } +} diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureModal.razor new file mode 100644 index 00000000..285b7d83 --- /dev/null +++ b/src/MauiSherpa/Pages/Modals/ProfilingCaptureModal.razor @@ -0,0 +1,2097 @@ +@page "/modal/profiling-capture" +@using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Models.Profiling +@using MauiSherpa.Core.Services +@using MauiSherpa.Pages.Forms +@inject HybridFormBridgeHolder BridgeHolder +@inject IMauiCliToolService MauiCli +@inject IMauiProfilingCliService Profiler +@inject IProfilingSessionStorageService SessionStorage +@inject IProfilingArtifactConverterService ArtifactConverter +@inject IDialogService DialogService +@inject INavigationService Navigation +@inject MauiSherpa.Services.ProfilingViewerService ViewerService +@implements IDisposable + +@{ + var bridge = BridgeHolder.Current; +} + +@if (bridge is not null) +{ +
+ @if (isInitializing) + { +
+ +
+ Checking MAUI profiling tools +

Finding the MAUI CLI and running targets...

+
+
+ } + else if (viewState == CaptureViewState.Configure) + { +
+ @RenderToolStatus() + + @if (toolStatus?.IsAvailable == true) + { +
+
+ 1 +
+

Project

+

Select the .NET MAUI project to build and launch.

+
+
+ +
+
+ + + @(string.IsNullOrWhiteSpace(projectPath) ? "Choose a .csproj" : projectPath) + +
+ +
+ + @if (recentProjectPaths.Count > 0) + { + + + } +
+ +
+
+ 2 +
+

Capture

+

Choose when recording should happen.

+
+
+ +
+ + +
+
+ +
+
+
+ 3 +
+

Running target

+

Android devices and emulators, or booted iOS simulators.

+
+
+ +
+ + @if (!string.IsNullOrWhiteSpace(deviceError)) + { + + } + else if (devices.Count == 0) + { +
+ +
+ No running targets found +

Start an Android target or boot an iOS simulator, then refresh.

+
+
+ + +
+
+ } + else + { +
+ @foreach (var device in devices) + { + var selected = selectedDeviceId == device.Identifier; + + } +
+ } +
+ +
+
+ 4 +
+

Output

+

Speedscope opens directly in Sherpa and keeps the raw trace.

+
+
+ +
+ @foreach (var format in OutputFormats) + { + var selected = selectedFormat == format; + + } +
+ + @if (selectedFormat == MauiProfileOutputFormat.Mibc) + { +
+ + The first MIBC capture may take several minutes while the CLI prepares dotnet-pgo. +
+ } +
+ +
+ + Advanced options + + +
+
+
+ + +
+ + @if (selectedMode == MauiProfileMode.Startup) + { +
+ + +
+ + @if (useFixedDuration) + { +
+ + +
+ } + } + +
+ + + Named profiles may be comma separated and are passed once to --trace-profile. +
+ + +
+ +
+
+ Command preview + +
+ @CommandPreview +
+
+
+ } +
+ } + else if (viewState == CaptureViewState.Running) + { +
+
+
+ @if (profileState == MauiProfileRunState.Recording) + { + + } + else + { + + } +
+
+ @GetRunEyebrow() +

@GetRunTitle()

+

@GetRunDescription()

+
+
+ + @if (activeRequest is not null) + { +
+
+ Project + @Path.GetFileNameWithoutExtension(activeRequest.ProjectPath) +
+
+ Target + @activeRequest.DeviceName +
+
+ Output + @GetFormatName(activeRequest.Format) +
+
+ } + + @if (profileState == MauiProfileRunState.AwaitingRecording) + { +
+ 1 +
+ Prepare the app +

Navigate to the starting point for the interaction you want to measure, then choose Begin recording.

+
+
+ } + else if (profileState == MauiProfileRunState.Recording) + { +
+ +
+ Recording interaction +

Perform the interaction now. Choose Stop recording when you are done.

+
+
+ } + +
+ CLI details +
+ @activeCommand + @if (statusMessages.Count > 0) + { +
+ @foreach (var status in statusMessages.TakeLast(6)) + { +
@status.Status@status.Message
+ } +
+ } +
+
+
+ } + else if (viewState == CaptureViewState.Completed && savedSession is not null) + { +
+
+ Profile saved +

@savedSession.Name

+

The capture is in your Sherpa profiling library.

+ + @if (executionResult?.Profile?.RecoveredFromDisk == true) + { +
+ + The MAUI CLI wrote this capture but crashed while reporting the result, so Sherpa recovered it from the output folder. The profile itself is complete. +
+ } + +
+
Mode@GetModeName(savedSession.MauiProfile?.Mode)
+
Target@savedSession.Target.DisplayName
+
Framework@FormatFramework(savedSession)
+
Duration@FormatDuration(savedSession.Pipeline?.Duration)
+
+ +
+ @foreach (var artifact in savedSession.Artifacts) + { +
+ + + @(artifact.DisplayName ?? artifact.FileName) + @artifact.FileName @FormatArtifactSize(artifact.SizeBytes) + +
+ } +
+ + @if (savedSession.MauiProfile?.Format != MauiProfileOutputFormat.Mibc) + { + + } +
+ } + else if (viewState == CaptureViewState.Failed) + { +
+
+ Profile not captured +

@(cliError?.Message ?? failureMessage ?? "The MAUI CLI could not complete the profile.")

+ + @if (cliError?.Remediation is { } remediation) + { +
+ How to recover + @foreach (var step in remediation.ManualSteps) + { +

@step

+ } + @if (!string.IsNullOrWhiteSpace(remediation.Command)) + { + @remediation.Command + } +
+ } + +
+ Technical details +
+ @if (cliError is not null) + { +
@cliError.Code @cliError.Category / @cliError.Severity
+ @if (!string.IsNullOrWhiteSpace(cliError.NativeError)) + { +
@cliError.NativeError
+ } + } + @if (!string.IsNullOrWhiteSpace(activeCommand)) + { + @activeCommand + } +
+
+
+ } +
+} + +@code { + private static readonly MauiProfileOutputFormat[] OutputFormats = + [ + MauiProfileOutputFormat.Speedscope, + MauiProfileOutputFormat.NetTrace, + MauiProfileOutputFormat.Mibc + ]; + + private CaptureViewState viewState = CaptureViewState.Configure; + private MauiCliToolStatus? toolStatus; + private List devices = []; + private List recentProjectPaths = []; + private readonly List statusMessages = []; + private MauiProfileMode selectedMode = MauiProfileMode.Startup; + private MauiProfileOutputFormat selectedFormat = MauiProfileOutputFormat.Speedscope; + private MauiProfileRunState profileState = MauiProfileRunState.Idle; + private string? projectPath; + private string? selectedDeviceId; + private string configuration = "Release"; + private string traceProfile = ""; + private bool useFixedDuration; + private int durationSeconds = 15; + private bool noBuild; + private bool isInitializing = true; + private bool isRefreshingDevices; + private bool isChangingTool; + private bool cancelRequested; + private string? deviceError; + private string? failureMessage; + private string? activeSessionId; + private string? activeCommand; + private MauiCliErrorMessage? cliError; + private MauiProfileRequest? activeRequest; + private MauiProfileExecutionResult? executionResult; + private ProfilingSessionManifest? savedSession; + private CancellationTokenSource? captureCts; + private Task? captureTask; + + private MauiCliDevice? SelectedDevice => + devices.FirstOrDefault(device => device.Identifier == selectedDeviceId); + + private bool CanPreviewCommand => + toolStatus?.IsAvailable == true && + !string.IsNullOrWhiteSpace(projectPath) && + SelectedDevice is not null; + + private bool CanStart => + CanPreviewCommand && + File.Exists(projectPath) && + durationSeconds > 0 && + !string.IsNullOrWhiteSpace(configuration) && + !isChangingTool && + !isRefreshingDevices; + + private string CommandPreview + { + get + { + if (!CanPreviewCommand) + return "Select a project and running target to preview the command."; + + try + { + var request = CreateRequest(Path.Combine("", "capture.nettrace")); + return MauiProfileCommandBuilder.FormatForDisplay(toolStatus!.ExecutablePath!, request); + } + catch (Exception ex) + { + return ex.Message; + } + } + } + + protected override async Task OnInitializedAsync() + { + var currentBridge = BridgeHolder.Current; + if (currentBridge is null) + return; + + currentBridge.SubmitRequested += OnPrimaryActionAsync; + currentBridge.CancelRequested += OnCancelRequested; + Profiler.StateChanged += OnProfileStateChanged; + Profiler.MessageReceived += OnProfileMessageReceived; + UpdateActionState(); + + try + { + var sessionsTask = SessionStorage.GetSessionsAsync(); + var statusTask = MauiCli.GetStatusAsync(); + await Task.WhenAll(sessionsTask, statusTask); + + recentProjectPaths = sessionsTask.Result + .Where(session => session.Project is not null && File.Exists(session.Project.Path)) + .Select(session => session.Project!.Path) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(6) + .ToList(); + projectPath = recentProjectPaths.FirstOrDefault(); + toolStatus = statusTask.Result; + + if (toolStatus.IsAvailable) + await LoadDevicesAsync(); + } + catch (Exception ex) + { + failureMessage = ex.Message; + } + finally + { + isInitializing = false; + UpdateActionState(); + } + } + + private RenderFragment RenderToolStatus() => __builder => + { + if (toolStatus?.State == MauiCliToolState.Missing) + { +
+ +
+ Install the MAUI CLI +

Sherpa uses the global Microsoft.Maui.Cli tool for zero-touch app profiling.

+
+ +
+ } + else if (toolStatus?.State == MauiCliToolState.UpdateRequired) + { +
+ +
+ Update the MAUI CLI +

@toolStatus.Message

+
+ +
+ } + else if (toolStatus?.IsAvailable == true) + { +
+ + MAUI CLI @(string.IsNullOrWhiteSpace(toolStatus.Version) ? "ready" : toolStatus.Version) +
+ } + else if (!string.IsNullOrWhiteSpace(failureMessage)) + { + + } + }; + + private async Task InstallOrUpdateToolAsync() + { + if (toolStatus is null || isChangingTool) + return; + + isChangingTool = true; + failureMessage = null; + UpdateActionState(); + + try + { + var result = toolStatus.State == MauiCliToolState.Missing + ? await MauiCli.InstallAsync() + : await MauiCli.UpdateAsync(); + if (!result.Success) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error); + + toolStatus = await MauiCli.GetStatusAsync(); + if (toolStatus.IsAvailable) + await LoadDevicesAsync(); + } + catch (Exception ex) + { + failureMessage = ex.Message; + } + finally + { + isChangingTool = false; + UpdateActionState(); + } + } + + private async Task BrowseProjectAsync() + { + var selectedPath = await DialogService.PickOpenFileAsync("Select .NET MAUI project", [".csproj"]); + if (string.IsNullOrWhiteSpace(selectedPath)) + return; + + projectPath = selectedPath; + if (!recentProjectPaths.Contains(selectedPath, StringComparer.OrdinalIgnoreCase)) + recentProjectPaths.Insert(0, selectedPath); + UpdateActionState(); + } + + private void SelectRecentProject(ChangeEventArgs args) + { + var value = args.Value?.ToString(); + if (!string.IsNullOrWhiteSpace(value)) + projectPath = value; + UpdateActionState(); + } + + private void SelectMode(MauiProfileMode mode) + { + selectedMode = mode; + if (mode == MauiProfileMode.Interaction) + useFixedDuration = false; + UpdateActionState(); + } + + private void SelectFormat(MauiProfileOutputFormat format) + { + selectedFormat = format; + UpdateActionState(); + } + + private void SelectDevice(MauiCliDevice device) + { + selectedDeviceId = device.Identifier; + UpdateActionState(); + } + + private void OnOptionsChanged() => UpdateActionState(); + + private async Task RefreshDevicesAsync() + { + if (isRefreshingDevices) + return; + + await LoadDevicesAsync(); + } + + private async Task LoadDevicesAsync() + { + isRefreshingDevices = true; + deviceError = null; + UpdateActionState(); + try + { + devices = (await MauiCli.GetDevicesAsync()).ToList(); + if (devices.Count == 1) + selectedDeviceId = devices[0].Identifier; + else if (devices.All(device => device.Identifier != selectedDeviceId)) + selectedDeviceId = null; + } + catch (Exception ex) + { + devices = []; + selectedDeviceId = null; + deviceError = ex.Message; + } + finally + { + isRefreshingDevices = false; + UpdateActionState(); + } + } + + private Task OnPrimaryActionAsync() + { + return viewState switch + { + CaptureViewState.Configure => StartCaptureAsync(), + CaptureViewState.Running when profileState == MauiProfileRunState.AwaitingRecording => + BeginRecordingAsync(), + CaptureViewState.Running when profileState == MauiProfileRunState.Recording => + StopRecordingAsync(), + CaptureViewState.Completed => OpenResultAndCloseAsync(), + CaptureViewState.Failed => ResetAfterFailureAsync(), + _ => Task.CompletedTask + }; + } + + private Task StartCaptureAsync() + { + if (!CanStart || projectPath is null || SelectedDevice is null) + return Task.CompletedTask; + + var projectName = Path.GetFileNameWithoutExtension(projectPath); + activeSessionId = SessionStorage.GenerateSessionId(projectName); + var sessionDirectory = SessionStorage.GetSessionDirectoryPath(activeSessionId); + activeRequest = CreateRequest(Path.Combine(sessionDirectory, "capture.nettrace")); + activeCommand = MauiProfileCommandBuilder.FormatForDisplay(toolStatus!.ExecutablePath!, activeRequest); + profileState = MauiProfileRunState.Starting; + viewState = CaptureViewState.Running; + statusMessages.Clear(); + failureMessage = null; + cliError = null; + cancelRequested = false; + captureCts = new CancellationTokenSource(); + UpdateActionState(); + + captureTask = RunCaptureAsync(activeRequest, captureCts.Token); + return Task.CompletedTask; + } + + private async Task RunCaptureAsync(MauiProfileRequest request, CancellationToken ct) + { + try + { + executionResult = await Profiler.RunAsync(request, ct); + cliError = executionResult.Error; + + if (executionResult.WasCancelled || cancelRequested) + { + await CleanupPendingSessionAsync(); + if (cancelRequested) + { + CloseWithoutResult(); + } + else + { + failureMessage ??= "The profiling process ended before the trace could be finalized."; + viewState = CaptureViewState.Failed; + } + return; + } + + if (!executionResult.Success || executionResult.Profile is null) + { + await CleanupPendingSessionAsync(); + failureMessage = executionResult.Error?.Message ?? + (string.IsNullOrWhiteSpace(executionResult.Process.Error) + ? "The MAUI CLI did not produce a profile." + : executionResult.Process.Error); + viewState = CaptureViewState.Failed; + return; + } + + profileState = MauiProfileRunState.Finalizing; + UpdateActionState(); + savedSession = await SessionStorage.SaveMauiProfileSessionAsync( + activeSessionId!, + request, + executionResult.Profile, + toolStatus?.Version, + ct); + BridgeHolder.Current!.Result = savedSession; + viewState = CaptureViewState.Completed; + } + catch (OperationCanceledException) when (cancelRequested || ct.IsCancellationRequested) + { + await CleanupPendingSessionAsync(); + if (cancelRequested) + { + CloseWithoutResult(); + } + else + { + failureMessage ??= "The profiling process was cancelled before the trace could be finalized."; + viewState = CaptureViewState.Failed; + } + } + catch (Exception ex) + { + await CleanupPendingSessionAsync(); + failureMessage = ex.Message; + viewState = CaptureViewState.Failed; + } + finally + { + captureCts?.Dispose(); + captureCts = null; + UpdateActionState(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task BeginRecordingAsync() + { + try + { + await Profiler.BeginRecordingAsync(); + } + catch (Exception ex) + { + failureMessage = ex.Message; + Profiler.Cancel(); + } + UpdateActionState(); + } + + private async Task StopRecordingAsync() + { + try + { + await Profiler.StopRecordingAsync(); + } + catch (Exception ex) + { + failureMessage = ex.Message; + Profiler.Cancel(); + } + UpdateActionState(); + } + + private void OnCancelRequested() + { + if (viewState == CaptureViewState.Running) + { + cancelRequested = true; + captureCts?.Cancel(); + Profiler.Cancel(); + UpdateActionState(); + } + else if (viewState == CaptureViewState.Completed) + { + BridgeHolder.Current?.RequestClose(); + } + } + + private void OnProfileStateChanged(object? sender, MauiProfileStateChangedEventArgs args) + { + _ = InvokeAsync(() => + { + profileState = args.NewState; + UpdateActionState(); + StateHasChanged(); + }); + } + + private void OnProfileMessageReceived(object? sender, MauiCliMessageEventArgs args) + { + _ = InvokeAsync(() => + { + switch (args.Message) + { + case MauiCliStatusMessage status: + statusMessages.Add(status); + break; + case MauiCliErrorMessage error: + cliError = error; + break; + } + StateHasChanged(); + }); + } + + private MauiProfileRequest CreateRequest(string outputPath) + { + var device = SelectedDevice ?? throw new InvalidOperationException("Select a running target."); + var platform = IsAndroid(device) ? ProfilingTargetPlatform.Android : ProfilingTargetPlatform.iOS; + return new MauiProfileRequest + { + ProjectPath = projectPath ?? throw new InvalidOperationException("Select a project."), + Platform = platform, + DeviceId = device.Identifier, + DeviceName = device.Name, + IsEmulator = device.IsEmulator, + Mode = selectedMode, + Format = selectedFormat, + OutputPath = outputPath, + Configuration = configuration, + Duration = selectedMode == MauiProfileMode.Startup && useFixedDuration + ? TimeSpan.FromSeconds(durationSeconds) + : null, + TraceProfile = string.IsNullOrWhiteSpace(traceProfile) ? null : traceProfile.Trim(), + NoBuild = noBuild + }; + } + + private async Task CopyCommandAsync() + { + if (!CanPreviewCommand) + return; + await DialogService.CopyToClipboardAsync(CommandPreview); + } + + private async Task OpenResultAndCloseAsync() + { + if (savedSession?.DirectoryPath is null) + return; + + var primary = GetPrimaryArtifact(savedSession); + if (primary is null) + throw new InvalidOperationException("The saved profile does not contain a viewable artifact."); + + var path = Path.Combine(savedSession.DirectoryPath, primary.FileName); + if (path.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + { + ViewerService.OpenSpeedscope(path); + } + else if (path.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + { + var converted = await ArtifactConverter.ConvertToSpeedscopeAsync(path); + if (string.IsNullOrWhiteSpace(converted) || !File.Exists(converted)) + throw new InvalidOperationException("The trace could not be converted to Speedscope format."); + ViewerService.OpenSpeedscope(converted); + } + else + { + RevealPath(path); + } + + BridgeHolder.Current?.RequestClose(); + } + + private void RevealPrimaryArtifact() + { + if (savedSession?.DirectoryPath is null) + return; + var primary = GetPrimaryArtifact(savedSession); + if (primary is not null) + RevealPath(Path.Combine(savedSession.DirectoryPath, primary.FileName)); + } + + private static ProfilingSessionArtifact? GetPrimaryArtifact(ProfilingSessionManifest session) + { + var format = session.MauiProfile?.Format; + return format switch + { + MauiProfileOutputFormat.Speedscope => + session.Artifacts.FirstOrDefault(artifact => + artifact.FileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)), + MauiProfileOutputFormat.Mibc => + session.Artifacts.FirstOrDefault(artifact => + artifact.FileName.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)), + _ => + session.Artifacts.FirstOrDefault(artifact => + artifact.FileName.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + } ?? session.Artifacts.FirstOrDefault(); + } + + private static void RevealPath(string path) + { + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) + { + var startInfo = new System.Diagnostics.ProcessStartInfo("open") + { + UseShellExecute = false + }; + startInfo.ArgumentList.Add("-R"); + startInfo.ArgumentList.Add(path); + System.Diagnostics.Process.Start(startInfo); + return; + } + + var directory = Path.GetDirectoryName(path) ?? path; + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(directory) + { + UseShellExecute = true + }); + } + + private Task ResetAfterFailureAsync() + { + viewState = CaptureViewState.Configure; + profileState = MauiProfileRunState.Idle; + failureMessage = null; + cliError = null; + executionResult = null; + activeRequest = null; + activeSessionId = null; + activeCommand = null; + UpdateActionState(); + return Task.CompletedTask; + } + + private async Task CleanupPendingSessionAsync() + { + if (string.IsNullOrWhiteSpace(activeSessionId)) + return; + + try + { + await SessionStorage.DeleteSessionAsync(activeSessionId); + } + catch + { + // The original CLI failure remains the actionable error shown to the user. + } + } + + private void CloseWithoutResult() + { + var currentBridge = BridgeHolder.Current; + if (currentBridge is null) + return; + currentBridge.Result = null; + currentBridge.RequestClose(); + } + + private async Task OpenTargetManagerAsync(string route) + { + CloseWithoutResult(); + await Task.Yield(); + await Navigation.NavigateToAsync(route); + } + + private void UpdateActionState() + { + var currentBridge = BridgeHolder.Current; + if (currentBridge is null) + return; + + currentBridge.PreventSubmitClose = true; + switch (viewState) + { + case CaptureViewState.Configure: + currentBridge.PreventClose = false; + currentBridge.SetActionState( + enabled: CanStart, + busy: isInitializing || isChangingTool || isRefreshingDevices, + actionText: isChangingTool ? "Preparing CLI..." : "Start profile", + secondaryActionText: "Cancel"); + break; + case CaptureViewState.Running: + currentBridge.PreventClose = true; + if (cancelRequested) + { + currentBridge.SetActionState(false, true, "Cancelling...", "Cancel profiling"); + } + else if (profileState == MauiProfileRunState.AwaitingRecording) + { + currentBridge.SetActionState(true, false, "Begin recording", "Cancel profiling"); + } + else if (profileState == MauiProfileRunState.Recording) + { + currentBridge.SetActionState(true, false, "Stop recording", "Cancel profiling"); + } + else + { + var actionText = profileState == MauiProfileRunState.Finalizing || + profileState == MauiProfileRunState.Completed + ? "Saving profile..." + : "Launching app..."; + currentBridge.SetActionState(false, true, actionText, "Cancel profiling"); + } + break; + case CaptureViewState.Completed: + currentBridge.PreventClose = true; + currentBridge.SetActionState( + true, + false, + savedSession?.MauiProfile?.Format == MauiProfileOutputFormat.Mibc ? "Reveal file" : "View result", + "Done"); + break; + case CaptureViewState.Failed: + currentBridge.PreventClose = false; + currentBridge.SetActionState(true, false, "Try again", "Cancel"); + break; + } + + _ = InvokeAsync(StateHasChanged); + } + + private string GetRunEyebrow() => profileState switch + { + MauiProfileRunState.AwaitingRecording => "Interaction profile", + MauiProfileRunState.Recording => "Recording", + MauiProfileRunState.Finalizing or MauiProfileRunState.Completed => "Finalizing", + _ => "MAUI CLI" + }; + + private string GetRunTitle() => profileState switch + { + MauiProfileRunState.AwaitingRecording => "Wait for the app, then navigate", + MauiProfileRunState.Recording => "Capture the interaction", + MauiProfileRunState.Finalizing or MauiProfileRunState.Completed => "Saving your profile", + _ => activeRequest?.Mode == MauiProfileMode.Startup ? "Profiling app startup" : "Launching your app" + }; + + private string GetRunDescription() => profileState switch + { + MauiProfileRunState.AwaitingRecording => "The CLI is building and launching. Once the app is visible, navigate to your starting point.", + MauiProfileRunState.Recording => "Sherpa is collecting trace data until you stop recording.", + MauiProfileRunState.Finalizing or MauiProfileRunState.Completed => "The CLI is finalizing trace data and converted artifacts.", + _ => "Building and launching through the MAUI CLI. This can take a moment." + }; + + private static bool IsAndroid(MauiCliDevice device) => + device.Platforms.Any(platform => platform.Equals("android", StringComparison.OrdinalIgnoreCase)); + + private static string GetDeviceDescription(MauiCliDevice device) + { + var platform = IsAndroid(device) ? "Android" : "iOS simulator"; + var type = device.IsEmulator ? "Emulator" : "Device"; + var version = !string.IsNullOrWhiteSpace(device.VersionName) ? $" {device.VersionName}" : + !string.IsNullOrWhiteSpace(device.Version) ? $" {device.Version}" : ""; + return IsAndroid(device) ? $"{platform}{version} · {type}" : $"{platform}{version}"; + } + + private static string GetFormatName(MauiProfileOutputFormat format) => format switch + { + MauiProfileOutputFormat.NetTrace => "Nettrace", + MauiProfileOutputFormat.Speedscope => "Speedscope", + MauiProfileOutputFormat.Mibc => "MIBC", + _ => format.ToString() + }; + + private static string GetFormatDescription(MauiProfileOutputFormat format) => format switch + { + MauiProfileOutputFormat.NetTrace => "Raw diagnostic trace", + MauiProfileOutputFormat.Speedscope => "Interactive CPU profile", + MauiProfileOutputFormat.Mibc => "Runtime optimization data", + _ => "" + }; + + private static string GetFormatIcon(MauiProfileOutputFormat format) => format switch + { + MauiProfileOutputFormat.NetTrace => "fa-wave-square", + MauiProfileOutputFormat.Speedscope => "fa-fire", + MauiProfileOutputFormat.Mibc => "fa-gauge-high", + _ => "fa-file" + }; + + private static string GetArtifactIcon(string fileName) + { + if (fileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + return "fa-fire"; + if (fileName.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + return "fa-wave-square"; + if (fileName.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)) + return "fa-gauge-high"; + return "fa-file"; + } + + private static string GetModeName(MauiProfileMode? mode) => mode switch + { + MauiProfileMode.Startup => "Startup", + MauiProfileMode.Interaction => "Interaction", + _ => "Imported" + }; + + private static string FormatDuration(TimeSpan? duration) + { + if (duration is null) + return "—"; + return duration.Value.TotalMinutes >= 1 + ? $"{(int)duration.Value.TotalMinutes}m {duration.Value.Seconds}s" + : $"{Math.Max(0, (int)duration.Value.TotalSeconds)}s"; + } + + private static string FormatFramework(ProfilingSessionManifest session) + { + var framework = session.MauiProfile?.Framework; + if (string.IsNullOrWhiteSpace(framework)) + framework = session.Project?.TargetFramework; + return string.IsNullOrWhiteSpace(framework) ? "—" : framework; + } + + private static string FormatArtifactSize(long? sizeBytes) + { + if (sizeBytes is null) + return ""; + if (sizeBytes >= 1024 * 1024) + return $"· {sizeBytes / (1024d * 1024d):F1} MB"; + if (sizeBytes >= 1024) + return $"· {sizeBytes / 1024d:F1} KB"; + return $"· {sizeBytes} B"; + } + + public void Dispose() + { + var currentBridge = BridgeHolder.Current; + if (currentBridge is not null) + { + currentBridge.SubmitRequested -= OnPrimaryActionAsync; + currentBridge.CancelRequested -= OnCancelRequested; + } + Profiler.StateChanged -= OnProfileStateChanged; + Profiler.MessageReceived -= OnProfileMessageReceived; + captureCts?.Cancel(); + captureCts?.Dispose(); + } + + private enum CaptureViewState + { + Configure, + Running, + Completed, + Failed + } +} + + diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCapturePage.cs b/src/MauiSherpa/Pages/Modals/ProfilingCapturePage.cs new file mode 100644 index 00000000..081fccd1 --- /dev/null +++ b/src/MauiSherpa/Pages/Modals/ProfilingCapturePage.cs @@ -0,0 +1,32 @@ +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Pages.Forms; +#if MACOSAPP +using Microsoft.Maui.Platforms.MacOS.Platform; +#endif +#if LINUXGTK +using Microsoft.Maui.Platforms.Linux.Gtk4.Platform; +#endif + +namespace MauiSherpa.Pages.Modals; + +public class ProfilingCapturePage : HybridFormPage +{ + protected override string FormTitle => "Capture profile"; + protected override string SubmitButtonText => "Start profile"; + protected override string BlazorRoute => "/modal/profiling-capture"; + protected override double ActionButtonHeight => 44; + + public ProfilingCapturePage(HybridFormBridgeHolder bridgeHolder) + : base(bridgeHolder) + { +#if MACOSAPP + MacOSPage.SetModalSheetSizesToContent(this, false); + MacOSPage.SetModalSheetWidth(this, 780); + MacOSPage.SetModalSheetHeight(this, 760); +#elif LINUXGTK + GtkPage.SetModalSizesToContent(this, false); + GtkPage.SetModalWidth(this, 780); + GtkPage.SetModalHeight(this, 760); +#endif + } +} diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor deleted file mode 100644 index c540d739..00000000 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardModal.razor +++ /dev/null @@ -1,2312 +0,0 @@ -@page "/modal/profiling-wizard" -@using MauiSherpa.Core.Interfaces -@using MauiSherpa.Core.Models.Profiling -@using MauiSherpa.Core.Requests.Android -@using MauiSherpa.Core.Requests.Apple -@using MauiSherpa.Core.Requests.Profiling -@using MauiSherpa.Pages.Forms -@using Shiny.Mediator -@inject HybridFormBridgeHolder BridgeHolder -@inject IMediator Mediator -@inject IProfilingCatalogService ProfilingCatalogService -@inject IDeviceMonitorService DeviceMonitor -@inject IDialogService DialogService -@inject IAlertService AlertService -@inject IPlatformService Platform -@inject IProfilingSessionRunner PipelineRunner -@inject IProfilingSessionStorageService SessionStorage -@inject IProfilingArtifactConverterService ArtifactConverter -@inject IJSRuntime JS -@implements IDisposable - -@{ - var bridge = BridgeHolder.Current; -} - -@if (bridge != null) -{ -
- @if (catalog is null) - { -
- - Loading profiling catalog... -
- } - else - { - -
- @for (int i = 0; i < WizardStepLabels.Length; i++) - { - var stepIndex = i; - var label = WizardStepLabels[i]; -
stepIndex ? "completed" : "")"> -
@(currentStep > stepIndex ? "✓" : (stepIndex + 1).ToString())
-
@label
-
- @if (i < WizardStepLabels.Length - 1) - { -
- } - } -
- -
- @if (currentStep == 0) - { -
-
-
- -
- - -
-
- Launch planning needs a project path. Connect-to-running-app flows can omit it if you provide a process id. -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - Advanced settings -
- @if (showAdvanced) - { -
- - -
- -
- - -
- -
- - -
- - @if (launchMode == ProfilingCaptureLaunchMode.Attach) - { -
- - -
- } - } -
-
- } - - @if (currentStep == 1) - { -
-
- - - @if (CurrentScenarioDefinition is not null) - { -
@CurrentScenarioDefinition.Description
- } -
- -
- @foreach (var captureKind in SupportedCaptureKinds) - { - - } -
- -
- -
- Pauses the app at startup until diagnostic tools connect. Useful for capturing startup performance. -
-
-
- } - - @if (currentStep == 2) - { -
-
- Targets - -
- @if (TargetOptions.Count == 0) - { -
- -
No targets are currently selectable for @GetPlatformDisplayName(selectedPlatform).
- @if (selectedPlatform == ProfilingTargetPlatform.Android && knownAndroidEmulatorCount > 0) - { -
You have @knownAndroidEmulatorCount Android emulator definition(s). Start one from the Emulators page to profile it here.
- } - @if (selectedPlatform == ProfilingTargetPlatform.iOS && knownAppleSimulatorCount > 0) - { -
You have @knownAppleSimulatorCount available Apple simulator(s). Select one after refreshing if it does not appear yet.
- } - -
- } - else - { -
- @foreach (var option in TargetOptions) - { - - } -
- } -
- } - - @if (currentStep == 3) - { -
-
- Prerequisites - @if (isCheckingPrerequisites) - { - Checking... - } - else if (prerequisiteReport is not null) - { -
- @if (!prerequisiteReport.HasErrors && !prerequisiteReport.HasWarnings) - { - All OK - } - else - { - @if (prerequisiteReport.WarningCount > 0) - { - @prerequisiteReport.WarningCount warning - } - @if (prerequisiteReport.ErrorCount > 0) - { - @prerequisiteReport.ErrorCount error - } - } - -
- } -
- @if (prerequisiteReport is not null && (prerequisiteReport.HasErrors || prerequisiteReport.HasWarnings || showPrereqDetails)) - { -
- @foreach (var check in prerequisiteReport.Checks) - { -
- - @check.Name - @if (!string.IsNullOrWhiteSpace(check.InstalledVersion)) - { - @check.InstalledVersion - } - @if (check.Status != DependencyStatusType.Ok && check.Status != DependencyStatusType.Info) - { - @check.Message - } - @if (!string.IsNullOrWhiteSpace(check.SuggestedCommand)) - { - @check.SuggestedCommand - } -
- } -
- } -
- -
-
- Capture plan - @if (isCheckingPrerequisites) - { - Waiting... - } - else if (isPlanningCapture) - { - Generating... - } - else if (capturePlan is not null) - { -
- - @(capturePlan.Validation.IsValid ? "Valid" : "Needs attention") - - - @(capturePlan.CanExecute ? "Executable" : "Preview only") - -
- } -
- @if (capturePlan is not null && !isPlanningCapture && !isCheckingPrerequisites) - { -
-
- Target framework - @capturePlan.TargetFramework -
-
- Output directory - @if (capturePlan.ExpectedArtifacts.Count > 0) - { - - @foreach (var artifact in capturePlan.ExpectedArtifacts) - { - @System.IO.Path.GetExtension(artifact.RelativePath) - } - - } -
- @capturePlan.OutputDirectory -
- - @if (capturePlan.Validation.Errors.Count > 0) - { -
-
Errors
-
    - @foreach (var error in capturePlan.Validation.Errors) - { -
  • @error
  • - } -
-
- } - - @if (capturePlan.Validation.Warnings.Count > 0) - { -
-
Warnings
-
    - @foreach (var warning in capturePlan.Validation.Warnings) - { -
  • @warning
  • - } -
-
- } - - @if (capturePlan.RuntimeBindings.Count > 0) - { -
-
Runtime bindings
-
    - @foreach (var binding in capturePlan.RuntimeBindings) - { -
  • - @binding.Token — @binding.Description - @if (!string.IsNullOrWhiteSpace(binding.ExampleValue)) - { - (example: @binding.ExampleValue) - } -
  • - } -
-
- } - -
Commands @capturePlan.Commands.Count
-
- @foreach (var step in capturePlan.Commands) - { - var stepId = step.DisplayName.Replace(" ", "-").ToLowerInvariant(); -
-
- @step.DisplayName - - - - @if (step.IsLongRunning) - { - Long-running - } - - - -
- @if (expandedCommands.Contains(stepId)) - { - @BuildCommandLine(step) - } -
- } -
- } -
- } - - @if (currentStep == 4) - { -
-
- Capture - @if (pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) - { - @FormatElapsed(pipelineElapsed) - } -
- @if (pipelineState == ProfilingPipelineState.NotStarted) - { -
- -
Starting capture pipeline...
-
- } - else - { -
- - @GetPipelineStateText() -
- -
- @foreach (var stepStatus in PipelineRunner.Steps) - { -
-
-
- @switch (stepStatus.State) - { - case ProfilingStepState.Running: - - break; - case ProfilingStepState.Completed: - - break; - case ProfilingStepState.Failed: - - break; - case ProfilingStepState.Stopped: - - break; - case ProfilingStepState.Skipped: - - break; - case ProfilingStepState.Cancelled: - - break; - default: - - break; - } -
-
-
@stepStatus.DisplayName
-
- @stepStatus.Kind - @if (stepStatus.IsLongRunning) - { - Long-running - } - @if (stepStatus.Duration.HasValue) - { - @FormatElapsed(stepStatus.Duration.Value) - } - else if (stepStatus.State == ProfilingStepState.Running && stepStatus.StartedAt.HasValue) - { - @FormatElapsed(DateTime.Now - stepStatus.StartedAt.Value) - } -
-
-
- -
-
- @if (stepStatus.ErrorMessage is not null && stepStatus.State == ProfilingStepState.Failed) - { -
- @stepStatus.ErrorMessage -
- } - @if (expandedStepLogs.Contains(stepStatus.StepId)) - { -
- @if (stepStatus.OutputLines.Count == 0) - { -
No output yet
- } - else - { - @foreach (var line in stepStatus.OutputLines.TakeLast(200)) - { -
@line.Text
- } - } -
- } -
- } -
- } -
- - @if (pipelineState == ProfilingPipelineState.WaitingForStop) - { - var hasTraceCapture = selectedCaptureKinds.Any(k => - k is ProfilingCaptureKind.Cpu or ProfilingCaptureKind.Startup - or ProfilingCaptureKind.Network or ProfilingCaptureKind.Rendering - or ProfilingCaptureKind.Energy or ProfilingCaptureKind.SystemTrace); - var hasMemoryCapture = selectedCaptureKinds.Contains(ProfilingCaptureKind.Memory); - - @if (hasTraceCapture || hasMemoryCapture) - { -
-
- On-demand Actions - (only one tool can use the diagnostic port at a time) -
-
- @if (hasTraceCapture) - { - @if (PipelineRunner.IsTraceActive) - { - - } - else - { - - } - @if (traceCount > 0) - { - @traceCount trace@(traceCount != 1 ? "s" : "") captured - } - } - - @if (hasMemoryCapture) - { - - @if (gcDumpCount > 0) - { - @gcDumpCount snapshot@(gcDumpCount != 1 ? "s" : "") collected - } - } -
-
- } - } - - @if (pipelineState == ProfilingPipelineState.Running) - { -
- - Launching capture pipeline... -
- } - } -
- } -
-} - -@code { - private ProfilingCatalog? catalog; - private ConnectedDevicesSnapshot snapshot = ConnectedDevicesSnapshot.Empty; - private List androidEmulators = new(); - private List appleSimulators = new(); - private List targetOptions = new(); - private HashSet selectedCaptureKinds = new(); - private ProfilingPrerequisiteReport? prerequisiteReport; - private ProfilingCapturePlan? capturePlan; - private string? selectedTargetKey; - private string? projectPath; - private string? targetFrameworkOverride; - private string? outputDirectory; - private string? processIdText; - private string configuration = "Release"; - private int diagnosticPort = 9000; - private bool suspendAtStartup = false; - private bool isRefreshingTargets; - private bool isCheckingPrerequisites; - private bool isPlanningCapture; - private int knownAndroidEmulatorCount; - private int knownAppleSimulatorCount; - private ProfilingTargetPlatform selectedPlatform = ProfilingTargetPlatform.Android; - private ProfilingScenarioKind selectedScenario = ProfilingScenarioKind.Launch; - private ProfilingCaptureLaunchMode launchMode = ProfilingCaptureLaunchMode.Launch; - - private int currentStep = 0; - private bool showAdvanced = false; - private bool showPrereqDetails = false; - - // Pipeline state - private ProfilingPipelineState pipelineState = ProfilingPipelineState.NotStarted; - private ProfilingPipelineResult? pipelineResult; - private TimeSpan pipelineElapsed; - private System.Threading.Timer? pipelineTimer; - private DateTime pipelineStartTime; - private HashSet expandedStepLogs = new(); - private HashSet expandedCommands = new(); - private bool isCollectingGcDump; - private int gcDumpCount; - private int traceCount; - - private string? activeSessionId; - - private static readonly string[] WizardStepLabels = new[] - { - "Project", - "Capture Kinds", - "Target", - "Review & Plan", - "Capture" - }; - - private bool CanProceedToNext => currentStep switch - { - 0 => launchMode == ProfilingCaptureLaunchMode.Attach || !string.IsNullOrWhiteSpace(projectPath), - 1 => selectedCaptureKinds.Count > 0, - 2 => SelectedTarget is not null, - 3 => capturePlan is not null && capturePlan.Validation.IsValid && !isCheckingPrerequisites && !isPlanningCapture, - _ => false - }; - - private IReadOnlyList TargetOptions => targetOptions; - private TargetOption? SelectedTarget => targetOptions.FirstOrDefault(option => option.Key == selectedTargetKey); - private ProfilingPlatformCapabilities? CurrentPlatformCapabilities => catalog?.Platforms.FirstOrDefault(platform => platform.Platform == selectedPlatform); - private ProfilingScenarioDefinition? CurrentScenarioDefinition => catalog?.Scenarios.FirstOrDefault(scenario => scenario.Kind == selectedScenario); - private IReadOnlyList SupportedCaptureKinds => CurrentPlatformCapabilities?.SupportedCaptureKinds ?? Array.Empty(); - private string? CurrentPlatformNotes => CurrentPlatformCapabilities?.Notes; - - protected override async Task OnInitializedAsync() - { - var bridge = BridgeHolder.Current; - if (bridge == null) return; - - bridge.SubmitRequested += OnSubmitRequested; - bridge.BackRequested += OnBackRequested; - bridge.NextRequested += OnNextRequested; - bridge.CancelRequested += OnCancelRequested; - - DeviceMonitor.Changed += OnDeviceMonitorChanged; - - catalog = await ProfilingCatalogService.GetCatalogAsync(); - selectedPlatform = catalog.Platforms.FirstOrDefault(platform => platform.Platform == ProfilingTargetPlatform.Android)?.Platform - ?? catalog.Platforms.First().Platform; - selectedScenario = catalog.Scenarios.FirstOrDefault(scenario => scenario.Kind == ProfilingScenarioKind.Launch)?.Kind - ?? catalog.Scenarios.First().Kind; - ResetCaptureKindsToScenarioDefaults(); - suspendAtStartup = selectedCaptureKinds.Contains(ProfilingCaptureKind.Startup); - - await RefreshTargetsAsync(); - UpdateWizardState(); - } - - private void UpdateWizardState() - { - var bridge = BridgeHolder.Current; - if (bridge == null) return; - - var isCapturing = currentStep == 4 && pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop or ProfilingPipelineState.Completing; - var canStop = currentStep == 4 && pipelineState == ProfilingPipelineState.WaitingForStop; - - bridge.PreventClose = isCapturing; - bridge.PreventSubmitClose = isCapturing; - - bridge.SetWizardState( - showBack: currentStep > 0 && currentStep != 4, - showNext: currentStep < 3, - showSubmit: currentStep == 3 || canStop, - canProceed: canStop || (CanProceedToNext && !isCapturing), - submitText: canStop ? "Stop Capture" : "Start Capture" - ); - } - - private async Task OnNextRequested() - { - if (!CanProceedToNext) return; - - if (currentStep == 2) - { - currentStep = 3; - await InvokeAsync(StateHasChanged); - await CheckPrerequisitesAsync(); - if (SelectedTarget is not null) - await GeneratePlanAsync(); - } - else - { - currentStep = Math.Min(currentStep + 1, 4); - } - - UpdateWizardState(); - await InvokeAsync(StateHasChanged); - } - - private Task OnBackRequested() - { - if (currentStep > 0 && currentStep != 4) - { - currentStep--; - UpdateWizardState(); - InvokeAsync(StateHasChanged); - } - return Task.CompletedTask; - } - - private async Task OnSubmitRequested() - { - if (currentStep == 4 && pipelineState == ProfilingPipelineState.WaitingForStop) - { - await HandleStopCapture(); - return; - } - - if (currentStep == 3 && CanProceedToNext) - { - // Start Capture — advance to step 4 and run the pipeline - currentStep = 4; - UpdateWizardState(); - await InvokeAsync(StateHasChanged); - await StartPipelineAsync(); - } - } - - private void OnDeviceMonitorChanged(ConnectedDevicesSnapshot updatedSnapshot) - { - snapshot = updatedSnapshot; - BuildTargetOptions(); - InvokeAsync(StateHasChanged); - } - - private async Task RefreshTargetsAsync(bool forceRefresh = false) - { - isRefreshingTargets = true; - StateHasChanged(); - - try - { - if (forceRefresh) - { - await Mediator.FlushStores("android:emulators"); - await Mediator.FlushStores("apple:simulators"); - } - - var emuTask = Mediator.Request(new GetEmulatorsRequest()); - var simTask = Mediator.Request(new GetSimulatorsRequest()); - - await Task.WhenAll(emuTask, simTask); - - var (_, emuResult) = await emuTask; - androidEmulators = emuResult?.ToList() ?? new(); - knownAndroidEmulatorCount = androidEmulators.Count; - - var (_, simResult) = await simTask; - appleSimulators = simResult?.Where(simulator => simulator.IsAvailable).ToList() ?? new(); - knownAppleSimulatorCount = appleSimulators.Count; - - snapshot = DeviceMonitor.Current; - BuildTargetOptions(); - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Error refreshing profiling targets: {ex.Message}"); - } - finally - { - isRefreshingTargets = false; - StateHasChanged(); - } - } - - private Task OnPlatformChangedAsync() - { - ResetCaptureKindsToScenarioDefaults(); - BuildTargetOptions(); - prerequisiteReport = null; - capturePlan = null; - UpdateWizardState(); - return Task.CompletedTask; - } - - private Task OnScenarioChangedAsync() - { - ResetCaptureKindsToScenarioDefaults(); - // Auto-toggle suspend based on whether Startup capture kind is selected - suspendAtStartup = selectedCaptureKinds.Contains(ProfilingCaptureKind.Startup); - prerequisiteReport = null; - capturePlan = null; - UpdateWizardState(); - return Task.CompletedTask; - } - - private Task OnLaunchModeChangedAsync() - { - capturePlan = null; - UpdateWizardState(); - return Task.CompletedTask; - } - - private void ResetCaptureKindsToScenarioDefaults() - { - var defaults = CurrentScenarioDefinition?.DefaultCaptureKinds - .Where(kind => SupportedCaptureKinds.Contains(kind)) - .ToArray(); - - selectedCaptureKinds = defaults is { Length: > 0 } - ? defaults.ToHashSet() - : SupportedCaptureKinds.ToHashSet(); - } - - private void SelectTarget(string key) - { - selectedTargetKey = key; - capturePlan = null; - } - - private void OnCaptureKindChanged(ProfilingCaptureKind kind, ChangeEventArgs args) - { - var isSelected = args.Value as bool? == true; - if (isSelected) - selectedCaptureKinds.Add(kind); - else if (selectedCaptureKinds.Count > 1) - selectedCaptureKinds.Remove(kind); - - // Auto-toggle suspend when Startup capture kind changes - if (kind == ProfilingCaptureKind.Startup) - suspendAtStartup = isSelected; - - capturePlan = null; - } - - private async Task BrowseProjectAsync() - { - var selectedPath = await DialogService.PickOpenFileAsync("Select project", new[] { ".csproj" }); - if (!string.IsNullOrWhiteSpace(selectedPath)) - { - projectPath = selectedPath; - capturePlan = null; - UpdateWizardState(); - StateHasChanged(); - } - } - - private async Task CheckPrerequisitesAsync() - { - isCheckingPrerequisites = true; - UpdateWizardState(); - StateHasChanged(); - - try - { - var captureKinds = GetSelectedCaptureKinds(); - var (_, report) = await Mediator.Request(new GetProfilingPrerequisitesRequest(selectedPlatform, captureKinds)); - prerequisiteReport = report; - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Unable to load profiling prerequisites: {ex.Message}"); - } - finally - { - isCheckingPrerequisites = false; - UpdateWizardState(); - StateHasChanged(); - } - } - - private async Task GeneratePlanAsync() - { - if (SelectedTarget is null) - { - await AlertService.ShowToastAsync("Select a profiling target first."); - return; - } - - isPlanningCapture = true; - UpdateWizardState(); - StateHasChanged(); - - try - { - var definition = ProfilingCatalogService.CreateSessionDefinition( - SelectedTarget.ToProfilingTarget(), - selectedScenario, - captureKinds: GetSelectedCaptureKinds()); - - var (_, plan) = await Mediator.Request(new PlanProfilingCaptureRequest(definition, BuildPlanOptions(SelectedTarget))); - - capturePlan = plan; - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Unable to generate profiling plan: {ex.Message}"); - } - finally - { - isPlanningCapture = false; - UpdateWizardState(); - StateHasChanged(); - } - } - - private ProfilingCapturePlanOptions BuildPlanOptions(TargetOption selectedTarget) - { - var additionalBuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (selectedTarget.Platform == ProfilingTargetPlatform.Android && - selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Emulator) - { - additionalBuildProperties["AdbTarget"] = $"-s {selectedTarget.Identifier}"; - } - - if (selectedTarget.Platform == ProfilingTargetPlatform.iOS && - selectedTarget.Kind is ProfilingTargetKind.PhysicalDevice or ProfilingTargetKind.Simulator) - { - additionalBuildProperties["_DeviceName"] = $":v2:udid={selectedTarget.Identifier}"; - } - - int? processId = null; - if (int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) - processId = parsedProcessId; - - // Pre-compute session storage path so commands are built with correct output dir - var effectiveOutputDir = outputDirectory; - if (string.IsNullOrWhiteSpace(effectiveOutputDir)) - { - var projectName = projectPath is not null - ? Path.GetFileNameWithoutExtension(projectPath) - : null; - var previewSessionId = SessionStorage.GenerateSessionId(projectName); - effectiveOutputDir = SessionStorage.GetSessionDirectoryPath(previewSessionId); - } - - return new ProfilingCapturePlanOptions( - ProjectPath: string.IsNullOrWhiteSpace(projectPath) ? null : projectPath, - Configuration: configuration, - TargetFramework: string.IsNullOrWhiteSpace(targetFrameworkOverride) ? null : targetFrameworkOverride, - OutputDirectory: effectiveOutputDir, - LaunchMode: launchMode, - DiagnosticPort: diagnosticPort, - SuspendAtStartup: suspendAtStartup, - ProcessId: processId, - AdditionalBuildProperties: additionalBuildProperties.Count == 0 ? null : additionalBuildProperties); - } - - private void BuildTargetOptions() - { - var options = new List(); - - switch (selectedPlatform) - { - case ProfilingTargetPlatform.Android: - options.AddRange(snapshot.AndroidDevices.Select(device => new TargetOption( - Key: $"android-device:{device.Serial}", - Platform: ProfilingTargetPlatform.Android, - Kind: ProfilingTargetKind.PhysicalDevice, - Identifier: device.Serial, - DisplayName: device.Model ?? device.Serial, - Subtitle: "Connected Android device", - KindLabel: "Device", - IsAvailable: true, - ExtraHint: device.State))); - - options.AddRange(snapshot.AndroidEmulators.Select(device => new TargetOption( - Key: $"android-emulator:{device.Serial}", - Platform: ProfilingTargetPlatform.Android, - Kind: ProfilingTargetKind.Emulator, - Identifier: device.Serial, - DisplayName: device.Model ?? device.Serial, - Subtitle: "Running Android emulator", - KindLabel: "Emulator", - IsAvailable: true, - ExtraHint: device.State))); - break; - - case ProfilingTargetPlatform.iOS: - options.AddRange(snapshot.ApplePhysicalDevices.Select(device => new TargetOption( - Key: $"ios-device:{device.Identifier}", - Platform: ProfilingTargetPlatform.iOS, - Kind: ProfilingTargetKind.PhysicalDevice, - Identifier: device.Identifier, - DisplayName: device.Name, - Subtitle: $"{device.ModelName} · iOS {device.OsVersion}", - KindLabel: "Device", - IsAvailable: device.IsAvailable, - ExtraHint: device.Interface))); - - options.AddRange(appleSimulators.Select(simulator => new TargetOption( - Key: $"ios-simulator:{simulator.Udid}", - Platform: ProfilingTargetPlatform.iOS, - Kind: ProfilingTargetKind.Simulator, - Identifier: simulator.Udid, - DisplayName: simulator.Name, - Subtitle: $"{simulator.Runtime ?? "Unknown runtime"} · {simulator.ProductFamily ?? "Simulator"}", - KindLabel: "Simulator", - IsAvailable: string.Equals(simulator.State, "Booted", StringComparison.OrdinalIgnoreCase), - ExtraHint: simulator.State))); - break; - - case ProfilingTargetPlatform.MacCatalyst: - if (Platform.IsMacCatalyst || Platform.IsMacOS) - { - options.Add(new TargetOption( - Key: "desktop:maccatalyst", - Platform: ProfilingTargetPlatform.MacCatalyst, - Kind: ProfilingTargetKind.Desktop, - Identifier: "local-maccatalyst", - DisplayName: "This Mac", - Subtitle: "Local Mac Catalyst target", - KindLabel: "Desktop", - IsAvailable: true)); - } - break; - - case ProfilingTargetPlatform.MacOS: - if (Platform.IsMacOS) - { - options.Add(new TargetOption( - Key: "desktop:macos", - Platform: ProfilingTargetPlatform.MacOS, - Kind: ProfilingTargetKind.Desktop, - Identifier: "local-macos", - DisplayName: "This Mac", - Subtitle: "Local macOS target", - KindLabel: "Desktop", - IsAvailable: true)); - } - break; - - case ProfilingTargetPlatform.Windows: - if (Platform.IsWindows) - { - options.Add(new TargetOption( - Key: "desktop:windows", - Platform: ProfilingTargetPlatform.Windows, - Kind: ProfilingTargetKind.Desktop, - Identifier: "local-windows", - DisplayName: "This PC", - Subtitle: "Local Windows target", - KindLabel: "Desktop", - IsAvailable: true)); - } - break; - } - - targetOptions = options; - if (selectedTargetKey is null || targetOptions.All(option => option.Key != selectedTargetKey)) - selectedTargetKey = targetOptions.FirstOrDefault()?.Key; - } - - private IReadOnlyList GetSelectedCaptureKinds() - => selectedCaptureKinds.Count > 0 - ? selectedCaptureKinds.OrderBy(kind => kind).ToArray() - : SupportedCaptureKinds; - - private async Task StartPipelineAsync() - { - if (capturePlan is null) return; - - // The plan already has the correct output directory from BuildPlanOptions. - // Derive the session ID from the output directory name. - activeSessionId = Path.GetFileName(capturePlan.OutputDirectory); - - pipelineState = ProfilingPipelineState.NotStarted; - pipelineResult = null; - expandedStepLogs.Clear(); - pipelineStartTime = DateTime.Now; - StateHasChanged(); - - PipelineRunner.PipelineStateChanged += OnPipelineStateChanged; - PipelineRunner.StepStateChanged += OnStepStateChanged; - PipelineRunner.StepOutputReceived += OnStepOutputReceived; - - pipelineTimer = new System.Threading.Timer(_ => - { - pipelineElapsed = DateTime.Now - pipelineStartTime; - InvokeAsync(() => - { - UpdateWizardState(); - StateHasChanged(); - }); - }, null, TimeSpan.Zero, TimeSpan.FromSeconds(1)); - - try - { - pipelineResult = await PipelineRunner.RunAsync(capturePlan); - pipelineState = pipelineResult.FinalState; - - // Save session and set bridge result - var manifest = await SaveSessionAsync(); - if (manifest is not null) - { - var bridge = BridgeHolder.Current; - if (bridge != null) - { - bridge.Result = manifest; - bridge.RequestClose(); - return; - } - } - } - catch (Exception ex) - { - pipelineState = ProfilingPipelineState.Failed; - await AlertService.ShowToastAsync($"Pipeline failed: {ex.Message}"); - } - finally - { - pipelineTimer?.Dispose(); - pipelineTimer = null; - PipelineRunner.PipelineStateChanged -= OnPipelineStateChanged; - PipelineRunner.StepStateChanged -= OnStepStateChanged; - PipelineRunner.StepOutputReceived -= OnStepOutputReceived; - UpdateWizardState(); - StateHasChanged(); - } - } - - private async Task SaveSessionAsync() - { - if (pipelineResult is null || capturePlan is null) return null; - - try - { - var projectName = projectPath is not null - ? Path.GetFileNameWithoutExtension(projectPath) - : null; - var sessionId = activeSessionId ?? SessionStorage.GenerateSessionId(projectName); - activeSessionId = sessionId; - - var target = SelectedTarget; - var manifest = new ProfilingSessionManifest - { - Id = sessionId, - Name = $"{projectName ?? "Session"} — {selectedPlatform} {target?.DisplayName ?? "Unknown"}", - Status = pipelineResult.Success ? ProfilingSessionStatus.Completed - : (pipelineState == ProfilingPipelineState.Cancelled ? ProfilingSessionStatus.Cancelled - : ProfilingSessionStatus.Failed), - CreatedAt = pipelineStartTime, - CompletedAt = DateTimeOffset.UtcNow, - Target = new ProfilingSessionTarget - { - Platform = selectedPlatform, - Kind = target?.Kind ?? ProfilingTargetKind.Desktop, - Identifier = target?.Identifier ?? "", - DisplayName = target?.DisplayName ?? selectedPlatform.ToString() - }, - Project = projectPath is not null ? new ProfilingSessionProject - { - Path = projectPath, - Name = projectName!, - Configuration = configuration, - TargetFramework = targetFrameworkOverride - } : null, - CaptureKinds = selectedCaptureKinds.ToList(), - Options = new ProfilingSessionOptions - { - LaunchMode = launchMode, - DiagnosticPort = diagnosticPort, - SuspendAtStartup = suspendAtStartup, - ProcessId = int.TryParse(processIdText, out var pid) ? pid : null, - Scenario = selectedScenario - }, - Pipeline = new ProfilingSessionPipelineSummary - { - Success = pipelineResult.Success, - Duration = pipelineResult.TotalDuration, - Steps = pipelineResult.StepResults.Select(step => new ProfilingSessionStepSummary - { - Id = step.StepId, - DisplayName = step.DisplayName, - State = step.State.ToString(), - CommandLine = capturePlan.Commands.FirstOrDefault(c => c.Id == step.StepId)?.CommandLine - }).ToList() - }, - Artifacts = new List() - }; - - var sessionDir = SessionStorage.GetSessionDirectoryPath(sessionId); - foreach (var artifactPath in pipelineResult.ArtifactPaths) - { - var fileName = Path.GetFileName(artifactPath); - var extension = GetArtifactExtension(fileName); - var kind = extension switch - { - ".nettrace" => ProfilingArtifactKind.Trace, - ".speedscope.json" => ProfilingArtifactKind.Export, - ".gcdump" => ProfilingArtifactKind.GcDump, - ".log" => ProfilingArtifactKind.Log, - _ => ProfilingArtifactKind.Other - }; - - long? size = null; - try { if (File.Exists(artifactPath)) size = new FileInfo(artifactPath).Length; } catch { } - - manifest.Artifacts.Add(new ProfilingSessionArtifact - { - FileName = fileName, - Kind = kind, - SizeBytes = size - }); - } - - if (Directory.Exists(sessionDir)) - { - // Scan for files not already in the artifact list (on-demand gcdumps, - // traces, and log files may have numbered names not in pipeline results) - foreach (var pattern in new[] { "*.log", "*.gcdump", "*.nettrace", "*.speedscope.json" }) - { - foreach (var file in Directory.GetFiles(sessionDir, pattern)) - { - var name = Path.GetFileName(file); - if (manifest.Artifacts.Any(a => a.FileName == name)) continue; - long? size = null; - try { size = new FileInfo(file).Length; } catch { } - - var ext = GetArtifactExtension(name); - var kind = ext switch - { - ".gcdump" => ProfilingArtifactKind.GcDump, - ".nettrace" => ProfilingArtifactKind.Trace, - ".speedscope.json" => ProfilingArtifactKind.Export, - _ => ProfilingArtifactKind.Log - }; - - manifest.Artifacts.Add(new ProfilingSessionArtifact - { - FileName = name, - Kind = kind, - SizeBytes = size - }); - } - } - } - - await SessionStorage.SaveSessionAsync(manifest); - return manifest; - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"Failed to save session: {ex.Message}"); - return null; - } - } - - private void OnPipelineStateChanged(object? sender, ProfilingPipelineStateChangedEventArgs e) - { - pipelineState = e.NewState; - InvokeAsync(() => - { - UpdateWizardState(); - StateHasChanged(); - }); - } - - private void OnStepStateChanged(object? sender, ProfilingStepStateChangedEventArgs e) - { - if (e.NewState == ProfilingStepState.Running) - { - expandedStepLogs.Add(e.StepId); - InvokeAsync(async () => - { - StateHasChanged(); - await Task.Yield(); - try { await JS.InvokeVoidAsync("logScrollInterop.track", $"step-log-{e.StepId}"); } catch { } - }); - return; - } - InvokeAsync(StateHasChanged); - } - - private void OnStepOutputReceived(object? sender, ProfilingStepOutputEventArgs e) - { - InvokeAsync(async () => - { - StateHasChanged(); - await Task.Yield(); - try { await JS.InvokeVoidAsync("logScrollInterop.scrollToBottom", $"step-log-{e.StepId}"); } catch { } - }); - } - - private async Task HandleStopCapture() - { - // Stop any active trace before stopping the pipeline - if (PipelineRunner.IsTraceActive) - { - await PipelineRunner.StopTraceAsync(); - } - await PipelineRunner.StopCaptureAsync(); - } - - private async Task CollectGcDumpOnDemandAsync() - { - if (isCollectingGcDump || PipelineRunner.IsTraceActive) return; - isCollectingGcDump = true; - StateHasChanged(); - - try - { - var path = await PipelineRunner.CollectGcDumpAsync(); - if (path is not null) - { - gcDumpCount++; - await AlertService.ShowToastAsync($"GC dump #{gcDumpCount} collected"); - } - else - { - await AlertService.ShowToastAsync("GC dump collection failed"); - } - } - catch (Exception ex) - { - await AlertService.ShowToastAsync($"GC dump failed: {ex.Message}"); - } - finally - { - isCollectingGcDump = false; - StateHasChanged(); - } - } - - private void StartTraceOnDemand() - { - if (PipelineRunner.IsTraceActive || isCollectingGcDump) return; - - var stepId = PipelineRunner.StartTraceAsync(); - if (stepId is not null) - { - traceCount++; - StateHasChanged(); - } - } - - private async Task StopTraceOnDemandAsync() - { - if (!PipelineRunner.IsTraceActive) return; - - await PipelineRunner.StopTraceAsync(); - await AlertService.ShowToastAsync($"Trace #{traceCount} captured"); - StateHasChanged(); - } - - private void HandleCancelPipeline() - { - PipelineRunner.Cancel(); - pipelineState = ProfilingPipelineState.Cancelled; - currentStep = 3; - UpdateWizardState(); - StateHasChanged(); - } - - private void OnCancelRequested() - { - if (currentStep == 4 && pipelineState is ProfilingPipelineState.Running or ProfilingPipelineState.WaitingForStop) - { - InvokeAsync(() => - { - HandleCancelPipeline(); - }); - } - } - - private async void ToggleStepLog(string stepId) - { - if (!expandedStepLogs.Remove(stepId)) - { - expandedStepLogs.Add(stepId); - StateHasChanged(); - await Task.Yield(); - try - { - await JS.InvokeVoidAsync("logScrollInterop.track", $"step-log-{stepId}"); - await JS.InvokeVoidAsync("logScrollInterop.scrollToBottom", $"step-log-{stepId}"); - } - catch { } - } - else - { - try { await JS.InvokeVoidAsync("logScrollInterop.untrack", $"step-log-{stepId}"); } catch { } - } - } - - private void ToggleCommandExpand(string commandId) - { - if (!expandedCommands.Remove(commandId)) - expandedCommands.Add(commandId); - } - - private string GetPipelineStateIcon() => pipelineState switch - { ProfilingPipelineState.Running => "fa-spinner fa-spin", - ProfilingPipelineState.WaitingForStop => "fa-circle-dot fa-beat-fade", - ProfilingPipelineState.Completing => "fa-spinner fa-spin", - ProfilingPipelineState.Completed => "fa-check-circle", - ProfilingPipelineState.Failed => "fa-times-circle", - ProfilingPipelineState.Cancelled => "fa-ban", - _ => "fa-circle" - }; - - private string GetPipelineStateText() => pipelineState switch - { - ProfilingPipelineState.Running => "Launching capture pipeline...", - ProfilingPipelineState.WaitingForStop => "Recording — click Stop Capture when ready", - ProfilingPipelineState.Completing => "Stopping capture and collecting artifacts...", - ProfilingPipelineState.Completed => "Capture completed successfully", - ProfilingPipelineState.Failed => "Capture failed — check step details below", - ProfilingPipelineState.Cancelled => "Capture cancelled", - _ => "Initializing..." - }; - - private static string FormatElapsed(TimeSpan ts) => - ts.TotalMinutes >= 1 ? $"{(int)ts.TotalMinutes}m {ts.Seconds}s" : $"{ts.Seconds}s"; - - private static string GetPlatformDisplayName(ProfilingTargetPlatform platform) => platform switch - { - ProfilingTargetPlatform.iOS => "iOS", - ProfilingTargetPlatform.MacCatalyst => "Mac Catalyst", - ProfilingTargetPlatform.MacOS => "macOS", - _ => platform.ToString() - }; - - private static string GetCaptureKindDisplayName(ProfilingCaptureKind captureKind) => captureKind switch - { - ProfilingCaptureKind.Cpu => "CPU trace", - ProfilingCaptureKind.Memory => "Memory / GC dump", - ProfilingCaptureKind.Network => "Network", - ProfilingCaptureKind.Rendering => "Rendering", - ProfilingCaptureKind.Energy => "Energy", - ProfilingCaptureKind.SystemTrace => "System trace", - ProfilingCaptureKind.Logs => "Logs", - ProfilingCaptureKind.Startup => "Startup", - _ => captureKind.ToString() - }; - - private static string GetDependencyCss(DependencyStatusType status) => status switch - { - DependencyStatusType.Ok => "ok", - DependencyStatusType.Info => "ok", - DependencyStatusType.Warning => "warning", - DependencyStatusType.Error => "error", - _ => "neutral" - }; - - private async Task CopyCommandAsync(ProfilingCommandStep step) - { - await DialogService.CopyToClipboardAsync(BuildCommandLine(step)); - await AlertService.ShowToastAsync("Copied command to clipboard."); - } - - private string BuildCommandLine(ProfilingCommandStep step) - { - var resolved = ResolveCommandStep(step); - return resolved?.CommandLine ?? step.CommandLine; - } - - private ProcessRequest? ResolveCommandStep(ProfilingCommandStep step) - { - if (step.RequiredRuntimeBindings is not null) - { - foreach (var binding in step.RequiredRuntimeBindings) - { - if (!TryResolveRuntimeBinding(binding, out _)) - return null; - } - } - - var resolvedArguments = step.Arguments.Select(ResolveRuntimeTokens).ToArray(); - var resolvedEnvironment = step.Environment?.ToDictionary( - kvp => kvp.Key, - kvp => ResolveRuntimeTokens(kvp.Value), - StringComparer.OrdinalIgnoreCase); - - return new ProcessRequest( - step.Command, - resolvedArguments, - step.WorkingDirectory, - Environment: resolvedEnvironment, - Title: step.DisplayName, - Description: step.Description); - } - - private string ResolveRuntimeTokens(string value) - { - foreach (var binding in capturePlan?.RuntimeBindings ?? Array.Empty()) - { - if (TryResolveRuntimeBinding(binding.Token, out var resolvedValue)) - value = value.Replace(binding.Token, resolvedValue, StringComparison.Ordinal); - } - - return value; - } - - private bool TryResolveRuntimeBinding(string token, out string value) - { - if (token == "{{PROCESS_ID}}" && int.TryParse(processIdText, out var parsedProcessId) && parsedProcessId > 0) - { - value = parsedProcessId.ToString(); - return true; - } - - value = string.Empty; - return false; - } - - private static string GetArtifactExtension(string path) - { - if (path.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) - return ".speedscope.json"; - return Path.GetExtension(path).ToLowerInvariant(); - } - - public void Dispose() - { - var bridge = BridgeHolder.Current; - if (bridge != null) - { - bridge.SubmitRequested -= OnSubmitRequested; - bridge.BackRequested -= OnBackRequested; - bridge.NextRequested -= OnNextRequested; - bridge.CancelRequested -= OnCancelRequested; - } - - DeviceMonitor.Changed -= OnDeviceMonitorChanged; - pipelineTimer?.Dispose(); - } - - private sealed record TargetOption( - string Key, - ProfilingTargetPlatform Platform, - ProfilingTargetKind Kind, - string Identifier, - string DisplayName, - string Subtitle, - string KindLabel, - bool IsAvailable, - string? ExtraHint = null) - { - public ProfilingTarget ToProfilingTarget() => new( - Platform, - Kind, - Identifier, - DisplayName); - } -} - - diff --git a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardPage.cs b/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardPage.cs deleted file mode 100644 index b58bbe30..00000000 --- a/src/MauiSherpa/Pages/Modals/ProfilingCaptureWizardPage.cs +++ /dev/null @@ -1,31 +0,0 @@ -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Pages.Forms; -#if MACOSAPP -using Microsoft.Maui.Platforms.MacOS.Platform; -#endif -#if LINUXGTK -using Microsoft.Maui.Platforms.Linux.Gtk4.Platform; -#endif - -namespace MauiSherpa.Pages.Modals; - -public class ProfilingCaptureWizardPage : WizardFormPage -{ - protected override string FormTitle => "New Profiling Session"; - protected override string DefaultSubmitText => "Start Capture"; - protected override string BlazorRoute => "/modal/profiling-wizard"; - - public ProfilingCaptureWizardPage(HybridFormBridgeHolder bridgeHolder) - : base(bridgeHolder) - { -#if MACOSAPP - MacOSPage.SetModalSheetSizesToContent(this, false); - MacOSPage.SetModalSheetWidth(this, 750); - MacOSPage.SetModalSheetHeight(this, 700); -#elif LINUXGTK - GtkPage.SetModalSizesToContent(this, false); - GtkPage.SetModalWidth(this, 750); - GtkPage.SetModalHeight(this, 700); -#endif - } -} diff --git a/src/MauiSherpa/Pages/Profiling.razor b/src/MauiSherpa/Pages/Profiling.razor index c86c6f0c..ad670c55 100644 --- a/src/MauiSherpa/Pages/Profiling.razor +++ b/src/MauiSherpa/Pages/Profiling.razor @@ -11,84 +11,138 @@ @inject IFormModalService FormModal @inject HybridFormBridgeHolder BridgeHolder @inject MauiSherpa.Services.ProfilingViewerService ViewerService +@inject IMauiCliToolService MauiCli +@inject ModalParameterService ModalParams +@inject IOperationModalService OperationModal @implements IDisposable @if (isLoadingSessions) +{ +
+ + Loading profiles... +
+} +else if (filteredSessions.Count == 0 && sessions.Count == 0) +{ +
+ +

Capture your first profile

+

Choose a MAUI project and a running Android device, emulator, or booted iOS simulator. Sherpa handles the CLI command and saves every result.

+ + New captures support Android and iOS simulator targets. Existing profiles from other platforms stay readable. +
+} +else +{ + @if (filteredSessions.Count == 0 && !string.IsNullOrEmpty(sessionSearchText)) { -
- - Loading sessions... -
- } - else if (filteredSessions.Count == 0 && sessions.Count == 0) - { -
- -

No profiling sessions yet

-

Capture CPU traces, memory dumps, and more for your .NET MAUI apps.

+
+ +

No matching profiles

+

Try a project, device, platform, mode, or output format.

} else { - @if (filteredSessions.Count == 0 && !string.IsNullOrEmpty(sessionSearchText)) - { -
- -

No matching sessions

-

Try a different search term.

-
- } - else - { -
- @foreach (var session in filteredSessions) - { - var isExpanded = expandedSessionId == session.Id; -
-
-
- -
-
-
@session.Name
-
- @session.CreatedAt.LocalDateTime.ToString("MMM d, yyyy h:mm tt") - @session.Target.DisplayName +
+ @foreach (var session in filteredSessions) + { + var isExpanded = expandedSessionId == session.Id; + var primaryArtifact = GetPrimaryArtifact(session); +
+
+
-
-
- @foreach (var kind in session.CaptureKinds) + + + + @if (session.MauiProfile is not null) { - @kind + @GetFormatDisplayName(session.MauiProfile.Format) + } + else + { + @foreach (var kind in session.CaptureKinds.Take(2)) + { + @kind + } } @session.Status -
-
- -
-
+ + + + + - @if (isExpanded) + @if (primaryArtifact is not null) { -
+ + } +
+ + @if (isExpanded) + { +
+
@if (session.Project is not null) {
@@ -97,71 +151,76 @@
}
- Platform - @session.Target.Platform — @session.Target.Kind + Target + @GetPlatformDisplayName(session.Target.Platform) · @session.Target.Kind
- @if (session.Pipeline is not null) + @if (session.MauiProfile is not null) {
- Pipeline - - @(session.Pipeline.Success ? "✓ Succeeded" : "✗ Failed") — - @session.Pipeline.Steps.Count step(s) - + Capture + @GetModeDisplayName(session) · @GetFormatDisplayName(session.MauiProfile.Format)
+ @if (!string.IsNullOrWhiteSpace(session.MauiProfile.CliVersion)) + { +
+ MAUI CLI + @session.MauiProfile.CliVersion +
+ } } +
- @{ - var profilingArtifacts = session.Artifacts.Where(a => !a.FileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase)).ToList(); - var logArtifacts = session.Artifacts.Where(a => a.FileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase)).ToList(); - } + @{ + var profilingArtifacts = session.Artifacts.Where(a => !a.FileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase)).ToList(); + var logArtifacts = session.Artifacts.Where(a => a.FileName.EndsWith(".log", StringComparison.OrdinalIgnoreCase)).ToList(); + } - @if (profilingArtifacts.Count > 0) - { -
Artifacts
+ @if (profilingArtifacts.Count > 0) + { +
Artifacts
+
+ @foreach (var artifact in profilingArtifacts) + { + @RenderArtifactCard(session, artifact) + } +
+ } + + @if (logArtifacts.Count > 0) + { +
+ + + Capture logs + @logArtifacts.Count +
- @foreach (var artifact in profilingArtifacts) + @foreach (var artifact in logArtifacts) { @RenderArtifactCard(session, artifact) }
- } - - @if (logArtifacts.Count > 0) - { -
- - - Log Files - @logArtifacts.Count - -
- @foreach (var artifact in logArtifacts) - { - @RenderArtifactCard(session, artifact) - } -
-
- } - -
- - - -
+
+ } + +
+ + +
- } -
- } -
- } +
+ } + + } +
} +} @code { private List sessions = new(); @@ -169,6 +228,9 @@ private bool isLoadingSessions; private string sessionSearchText = ""; private string? expandedSessionId; + private MauiCliToolStatus? cliStatus; + private MauiCliToolUpdateInfo? cliUpdateInfo; + private bool isCheckingCli; // Per-artifact error state (keyed by file path) private readonly Dictionary artifactErrors = new(); @@ -179,14 +241,131 @@ ToolbarService.ToolbarItemClicked += OnToolbarItemClicked; ToolbarService.SearchTextChanged += OnSearchTextChanged; await LoadSessionsAsync(); + _ = RefreshMauiCliStatusAsync(); + } + + private async Task RefreshMauiCliStatusAsync() + { + isCheckingCli = true; + await InvokeAsync(StateHasChanged); + + try + { + var status = await MauiCli.GetStatusAsync(); + var update = await MauiCli.GetUpdateInfoAsync(status); + cliStatus = status; + cliUpdateInfo = update; + } + catch (Exception ex) + { + cliStatus = new MauiCliToolStatus(MauiCliToolState.Missing, Message: ex.Message); + cliUpdateInfo = null; + } + finally + { + isCheckingCli = false; + await InvokeAsync(StateHasChanged); + } + } + + private string MauiCliIndicatorClass + { + get + { + if (isCheckingCli || cliStatus is null) + return "checking"; + if (cliStatus.State == MauiCliToolState.Missing) + return "missing"; + if (cliStatus.State == MauiCliToolState.UpdateRequired) + return "missing"; + if (cliUpdateInfo?.UpdateAvailable == true) + return "update-available"; + return "installed"; + } + } + + private string MauiCliIndicatorIcon => + isCheckingCli || cliStatus is null + ? "fas fa-spinner fa-spin" + : cliStatus.IsAvailable + ? "fa-regular fa-circle-check" + : "fa-regular fa-circle-xmark"; + + private string MauiCliIndicatorTitle + { + get + { + if (isCheckingCli || cliStatus is null) + return "MAUI CLI: checking status"; + if (cliStatus.State == MauiCliToolState.Missing) + return "The MAUI CLI is not installed. Open details."; + if (cliStatus.State == MauiCliToolState.UpdateRequired) + return "The MAUI CLI needs an update for profiling. Open details."; + if (cliUpdateInfo?.UpdateAvailable == true) + return $"MAUI CLI {cliUpdateInfo.LatestVersion} is available. Open details."; + return "The MAUI CLI is ready. Open details."; + } + } + + private async Task OpenMauiCliDetailsAsync() + { + var status = cliStatus ?? new MauiCliToolStatus(MauiCliToolState.Missing); + var page = new MauiSherpa.Pages.Modals.MauiCliDetailsPage(ModalParams, status, cliUpdateInfo); + await FormModal.ShowViewAsync(page, page.WaitForCloseAsync); + + switch (page.SelectedAction) + { + case MauiSherpa.Pages.Modals.MauiCliDetailsAction.Install: + await RunMauiCliToolOperationAsync(isUpdate: false); + break; + case MauiSherpa.Pages.Modals.MauiCliDetailsAction.Update: + await RunMauiCliToolOperationAsync(isUpdate: true); + break; + case MauiSherpa.Pages.Modals.MauiCliDetailsAction.Recheck: + await RefreshMauiCliStatusAsync(); + break; + } + } + + private async Task RunMauiCliToolOperationAsync(bool isUpdate) + { + var title = isUpdate ? "Update MAUI CLI" : "Install MAUI CLI"; + var result = await OperationModal.RunAsync( + title, + "Running dotnet tool for Microsoft.Maui.Cli", + async ctx => + { + ctx.SetStatus(isUpdate ? "Updating Microsoft.Maui.Cli..." : "Installing Microsoft.Maui.Cli..."); + var process = isUpdate + ? await MauiCli.UpdateAsync(cliUpdateInfo?.LatestVersion, ctx.CancellationToken) + : await MauiCli.InstallAsync(cliUpdateInfo?.LatestVersion, ctx.CancellationToken); + + if (!string.IsNullOrWhiteSpace(process.Output)) + ctx.LogInfo(process.Output.Trim()); + if (!string.IsNullOrWhiteSpace(process.Error)) + ctx.LogError(process.Error.Trim()); + + if (process.Success) + ctx.LogSuccess(isUpdate ? "MAUI CLI updated" : "MAUI CLI installed"); + + return process.Success; + }, + canCancel: true); + + if (result.Success) + await AlertService.ShowToastAsync(isUpdate ? "MAUI CLI updated" : "MAUI CLI installed"); + + await RefreshMauiCliStatusAsync(); } private void OnToolbarItemClicked(string actionId) { if (actionId == "create") - _ = InvokeAsync(StartNewWizardAsync); - else if (actionId == "import") - _ = ImportSessionFromZipAsync(); + _ = InvokeAsync(StartCaptureAsync); + else if (actionId == "import-artifact") + _ = ImportArtifactAsync(); + else if (actionId == "import-session") + _ = ImportSessionArchiveAsync(); else if (actionId == "refresh") _ = LoadSessionsAsync(); } @@ -198,9 +377,9 @@ InvokeAsync(StateHasChanged); } - private async Task StartNewWizardAsync() + private async Task StartCaptureAsync() { - var page = new MauiSherpa.Pages.Modals.ProfilingCaptureWizardPage(BridgeHolder); + var page = new MauiSherpa.Pages.Modals.ProfilingCapturePage(BridgeHolder); var result = await FormModal.ShowAsync(page); if (result is not null) @@ -215,11 +394,12 @@ { ToolbarService.ClearItems(); ToolbarService.SetItems( - new ToolbarAction("create", "New Session", "plus"), - new ToolbarAction("import", "Import", "square.and.arrow.down"), + new ToolbarAction("create", "Capture Profile", "record.circle"), + new ToolbarAction("import-artifact", "Import Artifact", "doc.badge.plus"), + new ToolbarAction("import-session", "Import Session", "archivebox"), new ToolbarAction("refresh", "Refresh", "arrow.clockwise") ); - ToolbarService.SetSearch("Search sessions..."); + ToolbarService.SetSearch("Search profiles..."); } private async Task LoadSessionsAsync() @@ -256,7 +436,10 @@ s.Name.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || s.Target.DisplayName.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || s.Target.Platform.ToString().Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) || - (s.Project?.Name?.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false)) + (s.Project?.Name?.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false) || + (s.MauiProfile?.Mode.ToString().Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false) || + (s.MauiProfile?.Format.ToString().Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false) || + (s.MauiProfile?.Framework?.Contains(sessionSearchText, StringComparison.OrdinalIgnoreCase) ?? false)) .ToList(); } } @@ -310,7 +493,28 @@ } } - private async Task ImportSessionFromZipAsync() + private async Task ImportArtifactAsync() + { + try + { + var filePath = await DialogService.PickOpenFileAsync( + "Import Profiling Artifact", + [".nettrace", ".json", ".mibc", ".gcdump"]); + if (string.IsNullOrEmpty(filePath)) + return; + + var imported = await SessionStorage.ImportArtifactAsync(filePath); + await LoadSessionsAsync(); + expandedSessionId = imported.Id; + await AlertService.ShowToastAsync($"Imported \"{imported.Name}\""); + } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Artifact import failed: {ex.Message}"); + } + } + + private async Task ImportSessionArchiveAsync() { try { @@ -335,12 +539,37 @@ } } - private void RevealSessionFolder(ProfilingSessionManifest session) + private async Task RevealSessionFolderAsync(ProfilingSessionManifest session) { - if (session.DirectoryPath is null) return; - RevealInFinder(session.DirectoryPath); + if (session.DirectoryPath is null) + return; + await RevealInFinderAsync(session.DirectoryPath); } + private static string GetModeDisplayName(ProfilingSessionManifest session) => + session.MauiProfile?.Mode switch + { + MauiProfileMode.Startup => "Startup", + MauiProfileMode.Interaction => "Interaction", + _ => session.CaptureKinds.FirstOrDefault().ToString() + }; + + private static string GetFormatDisplayName(MauiProfileOutputFormat format) => format switch + { + MauiProfileOutputFormat.NetTrace => "Nettrace", + MauiProfileOutputFormat.Speedscope => "Speedscope", + MauiProfileOutputFormat.Mibc => "MIBC", + _ => format.ToString() + }; + + private static string GetPlatformDisplayName(ProfilingTargetPlatform platform) => platform switch + { + ProfilingTargetPlatform.iOS => "iOS", + ProfilingTargetPlatform.MacOS => "macOS", + ProfilingTargetPlatform.MacCatalyst => "Mac Catalyst", + _ => platform.ToString() + }; + private static string GetPlatformIcon(ProfilingTargetPlatform platform) => platform switch { ProfilingTargetPlatform.Android => "fa-brands fa-android", @@ -352,19 +581,85 @@ }; private static string FormatElapsed(TimeSpan ts) => - ts.TotalMinutes >= 1 ? $"{(int)ts.TotalMinutes}m {ts.Seconds}s" : $"{ts.Seconds}s"; + ts.TotalMinutes >= 1 + ? $"{(int)ts.TotalMinutes}m {ts.Seconds}s" + : $"{Math.Max(0, (int)ts.TotalSeconds)}s"; - private static string? GetFileSize(string path) + private static ProfilingSessionArtifact? GetPrimaryArtifact(ProfilingSessionManifest session) { - try + if (session.MauiProfile?.Format == MauiProfileOutputFormat.Speedscope) + { + var speedscope = session.Artifacts.FirstOrDefault(a => + a.FileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)); + if (speedscope is not null) + return speedscope; + } + + if (session.MauiProfile?.Format == MauiProfileOutputFormat.Mibc) + { + var mibc = session.Artifacts.FirstOrDefault(a => + a.FileName.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)); + if (mibc is not null) + return mibc; + } + + return session.Artifacts.FirstOrDefault(a => + a.FileName.EndsWith(".speedscope.json", StringComparison.OrdinalIgnoreCase)) + ?? session.Artifacts.FirstOrDefault(a => + a.FileName.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) + ?? session.Artifacts.FirstOrDefault(a => + a.FileName.EndsWith(".gcdump", StringComparison.OrdinalIgnoreCase)) + ?? session.Artifacts.FirstOrDefault(a => + a.FileName.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)); + } + + private static string GetPrimaryActionText(ProfilingSessionArtifact artifact) + { + var extension = GetArtifactExtension(artifact.FileName); + return extension switch + { + ".speedscope.json" => "View profile", + ".nettrace" => "Convert and view", + ".gcdump" => "View heap", + ".mibc" => "Reveal file", + _ => "Open" + }; + } + + private static string GetPrimaryActionIcon(ProfilingSessionArtifact artifact) + { + var extension = GetArtifactExtension(artifact.FileName); + return extension switch + { + ".speedscope.json" => "fa-fire", + ".nettrace" => "fa-wand-magic-sparkles", + ".gcdump" => "fa-memory", + ".mibc" => "fa-folder-open", + _ => "fa-arrow-up-right-from-square" + }; + } + + private async Task OpenPrimaryArtifactAsync( + ProfilingSessionManifest session, + ProfilingSessionArtifact artifact) + { + if (session.DirectoryPath is null) + return; + + var path = Path.Combine(session.DirectoryPath, artifact.FileName); + switch (GetArtifactExtension(artifact.FileName)) { - if (!File.Exists(path)) return null; - var bytes = new FileInfo(path).Length; - if (bytes >= 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; - if (bytes >= 1024) return $"{bytes / 1024.0:F1} KB"; - return $"{bytes} B"; + case ".speedscope.json": + case ".nettrace": + await ViewInSpeedscope(path); + break; + case ".gcdump": + ViewGcDump(path); + break; + case ".mibc": + await RevealInFinderAsync(path); + break; } - catch { return null; } } private RenderFragment RenderArtifactCard(ProfilingSessionManifest session, ProfilingSessionArtifact artifact) => __builder => @@ -375,9 +670,7 @@ var extension = GetArtifactExtension(artifact.FileName); var isSpeedscopeFile = extension is ".speedscope.json"; var isNettraceFile = extension is ".nettrace"; - // On macOS, .nettrace View is redundant (speedscope.json is alongside it). - // Only show View for .nettrace on Windows where PerfView can open it. - var isTraceFile = isSpeedscopeFile || (isNettraceFile && OperatingSystem.IsWindows()); + var isTraceFile = isSpeedscopeFile || isNettraceFile; var isGcDump = extension is ".gcdump";
@@ -404,10 +697,10 @@ View } - -
@@ -438,6 +731,7 @@ { ".nettrace" => "fa-wave-square", ".speedscope.json" => "fa-fire", + ".mibc" => "fa-gauge-high", ".gcdump" => "fa-memory", ".txt" or ".log" => "fa-file-lines", ".json" => "fa-code", @@ -507,21 +801,30 @@ ViewerService.OpenGcDump(path); } - private void RevealInFinder(string path) + private async Task RevealInFinderAsync(string path) { try { - var dir = Path.GetDirectoryName(path) ?? path; if (Platform.IsMacCatalyst || Platform.IsMacOS) { - System.Diagnostics.Process.Start("open", $"-R \"{path}\""); + var startInfo = new System.Diagnostics.ProcessStartInfo("open") + { + UseShellExecute = false + }; + startInfo.ArgumentList.Add("-R"); + startInfo.ArgumentList.Add(path); + System.Diagnostics.Process.Start(startInfo); } else { + var dir = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path; System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir) { UseShellExecute = true }); } } - catch { } + catch (Exception ex) + { + await AlertService.ShowToastAsync($"Unable to reveal file: {ex.Message}"); + } } private async Task CopyPathAsync(string path) @@ -546,10 +849,70 @@ margin-bottom: 1.2rem; } + .page-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + .page-title-row h1 { margin: 0; } + .maui-cli-indicator { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2rem; + padding: 0.3rem 0.5rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + } + + .maui-cli-indicator:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + .maui-cli-indicator:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; + } + + .maui-cli-indicator:disabled { + cursor: default; + opacity: 0.7; + } + + .maui-cli-indicator.installed > i, + .maui-cli-indicator.update-available > i { + color: color-mix(in srgb, var(--status-success-text) 72%, var(--text-secondary)); + } + + .maui-cli-indicator.missing > i { + color: color-mix(in srgb, var(--status-warning-text) 72%, var(--text-secondary)); + } + + .maui-cli-indicator-update { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 999px; + background: var(--status-info-bg); + color: var(--status-info-text); + font-size: 0.58rem; + } + .page-subtitle { margin: 0; color: var(--text-secondary); @@ -573,16 +936,31 @@ text-align: center; padding: 60px 40px; color: var(--text-muted); + border: 1px dashed var(--border-color); + border-radius: 16px; + background: var(--card-bg); } - .empty-state i { - margin-bottom: 16px; - opacity: 0.4; + .empty-state.compact { + padding: 40px; } - .empty-state h3 { + .empty-icon { + width: 4rem; + height: 4rem; + display: grid; + place-items: center; + margin-bottom: 0.5rem; + border-radius: 1rem; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 13%, transparent); + font-size: 1.5rem; + } + + .empty-state h2 { color: var(--text-primary); - margin-bottom: 8px; + margin: 0; + font-size: 1.25rem; } .empty-state p { @@ -591,6 +969,16 @@ line-height: 1.5; } + .empty-state small { + max-width: 36rem; + line-height: 1.45; + } + + .empty-action { + min-height: 44px; + margin-bottom: 0.2rem; + } + .session-list { display: grid; gap: 0.6rem; @@ -605,7 +993,7 @@ } .session-card.expanded { - border-color: var(--accent-primary, #0078d4); + border-color: var(--accent-primary); } .session-card-header { @@ -613,10 +1001,31 @@ align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; + } + + .session-card-main { + min-width: 0; + min-height: 44px; + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0; + color: var(--text-primary); + text-align: left; + border: 0; + background: transparent; cursor: pointer; user-select: none; } + .session-card-main:focus-visible, + .session-primary-action:focus-visible, + .btn-icon-sm:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent-primary) 55%, transparent); + outline-offset: 3px; + } + .session-card-icon { font-size: 1.3rem; width: 2rem; @@ -627,9 +1036,11 @@ .session-card-info { flex: 1; min-width: 0; + display: block; } .session-card-title { + display: block; font-weight: 600; font-size: 0.9rem; white-space: nowrap; @@ -667,18 +1078,18 @@ } .badge-kind { - background: color-mix(in srgb, var(--accent-primary, #0078d4) 15%, transparent); - color: var(--accent-primary, #0078d4); + background: color-mix(in srgb, var(--accent-primary) 15%, transparent); + color: var(--accent-primary); } .badge-status { color: white; } - .badge-completed { background: #16a34a; } - .badge-failed { background: #dc2626; } - .badge-cancelled { background: #ca8a04; } - .badge-inprogress { background: #2563eb; } + .badge-completed { background: var(--accent-success); } + .badge-failed { background: var(--accent-danger); } + .badge-cancelled { background: var(--accent-warning); } + .badge-inprogress { background: var(--accent-primary); } .session-card-expand { color: var(--text-muted); @@ -686,6 +1097,12 @@ padding: 0 0.25rem; } + .session-primary-action { + min-height: 40px; + flex-shrink: 0; + white-space: nowrap; + } + .session-card-body { border-top: 1px solid var(--border-color); padding: 1rem 1rem 1.25rem; @@ -712,12 +1129,21 @@ .session-card-actions { display: flex; + align-items: center; gap: 0.5rem; margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border-color); } + .session-card-actions .btn { + min-height: 40px; + } + + .destructive-action { + margin-left: auto; + } + .plan-section-title { font-weight: 700; margin-top: 1rem; @@ -830,7 +1256,11 @@ border: none; color: var(--text-tertiary); cursor: pointer; - padding: 0.25rem 0.35rem; + width: 36px; + height: 36px; + display: grid; + place-items: center; + padding: 0; border-radius: 6px; font-size: 0.75rem; transition: color 0.15s, background 0.15s; @@ -859,9 +1289,9 @@ .missing-artifact { opacity: 0.7; } - .text-success { color: #22c55e; } - .text-danger { color: #ef4444; } - .text-warning { color: #eab308; } + .text-success { color: var(--accent-success); } + .text-danger { color: var(--accent-danger); } + .text-warning { color: var(--accent-warning); } .text-muted { color: var(--text-secondary); } .artifact-error { @@ -887,4 +1317,27 @@ flex-wrap: wrap; gap: 0.4rem; } + + @@media (max-width: 760px) { + .session-card-header { + align-items: stretch; + flex-direction: column; + } + + .session-card-badges { + display: none; + } + + .session-primary-action { + align-self: flex-end; + } + } + + @@media (prefers-reduced-motion: reduce) { + .session-card, + .toggle-chevron, + .btn-icon-sm { + transition: none; + } + } diff --git a/src/MauiSherpa/Services/ProcessExecutionService.cs b/src/MauiSherpa/Services/ProcessExecutionService.cs index 1b16be8a..c8e86726 100644 --- a/src/MauiSherpa/Services/ProcessExecutionService.cs +++ b/src/MauiSherpa/Services/ProcessExecutionService.cs @@ -13,6 +13,7 @@ public class ProcessExecutionService : IProcessExecutionService private Process? _currentProcess; private readonly StringBuilder _outputBuilder = new(); private readonly StringBuilder _errorBuilder = new(); + private readonly object _outputLock = new(); private ProcessState _currentState = ProcessState.Pending; private readonly object _stateLock = new(); private CancellationTokenSource? _linkedCts; @@ -60,8 +61,11 @@ public async Task ExecuteAsync(ProcessRequest request, Cancellati nameof(request)); } - _outputBuilder.Clear(); - _errorBuilder.Clear(); + lock (_outputLock) + { + _outputBuilder.Clear(); + _errorBuilder.Clear(); + } lock (_stateLock) { _acceptsStandardInput = request.AcceptsStandardInput; @@ -104,7 +108,7 @@ public async Task ExecuteAsync(ProcessRequest request, Cancellati CurrentState = ProcessState.Failed; var duration = DateTime.Now - _startTime; OnOutput($"\n❌ Error: {ex.Message}", isError: true); - return new ProcessResult(-1, _outputBuilder.ToString(), ex.Message, duration, ProcessState.Failed); + return new ProcessResult(-1, GetOutputSnapshot().Output, ex.Message, duration, ProcessState.Failed); } finally { @@ -360,10 +364,11 @@ private async Task ExecuteElevatedMacAsync(ProcessRequest request } CurrentState = finalState; + var output = GetOutputSnapshot(); return new ProcessResult( exitCode, - _outputBuilder.ToString(), - _errorBuilder.ToString(), + output.Output, + output.Error, DateTime.Now - _startTime, finalState ); @@ -448,10 +453,11 @@ await File.WriteAllTextAsync( finalState = exitCode == 0 ? ProcessState.Completed : ProcessState.Failed; CurrentState = finalState; + var output = GetOutputSnapshot(); return new ProcessResult( exitCode, - _outputBuilder.ToString(), - _errorBuilder.ToString(), + output.Output, + output.Error, DateTime.Now - _startTime, finalState); } @@ -537,15 +543,16 @@ private ProcessResult CreateResult() } CurrentState = finalState; + var output = GetOutputSnapshot(); return new ProcessResult( exitCode, - _outputBuilder.ToString(), - _errorBuilder.ToString(), + output.Output, + output.Error, duration, finalState ); } - + private void CleanupTempFiles() { if (_tempOutputFile != null) @@ -557,7 +564,9 @@ private void CleanupTempFiles() public void Cancel() { - if (_currentProcess == null || _currentProcess.HasExited) return; + var process = _currentProcess; + if (process == null || process.HasExited) return; + var linkedCts = _linkedCts; _logger.LogInformation("Sending cancel signal to process"); OnOutput("\n⚠️ Cancellation requested...", isError: false); @@ -568,9 +577,8 @@ public void Cancel() if (_platform.IsMacCatalyst || _platform.IsMacOS) { // Send SIGINT on Unix — let the process flush and exit on its own. - // WaitForExitAsync (called by the pipeline runner) will complete naturally - // once the process finishes writing output and exits. - SendSignal(_currentProcess.Id, 2); // SIGINT + // The active execution waits for the process to flush output and exit. + SendSignal(process.Id, 2); // SIGINT } else { @@ -578,8 +586,9 @@ public void Cancel() _inputWriteLock.Wait(); try { - if (_currentProcess is { HasExited: false } process) + if (!process.HasExited) { + // On Windows, try to send Ctrl+C process.StandardInput.WriteLine("\x03"); process.StandardInput.Close(); } @@ -595,18 +604,40 @@ public void Cancel() _ = Task.Run(async () => { await Task.Delay(30_000); - if (_currentProcess is { HasExited: false }) + try { - OnOutput("Process did not exit within 30s after SIGINT — forcing cancellation.", isError: true); - _linkedCts?.Cancel(); + if (!process.HasExited) + { + OnOutput("Process did not exit within 30s after SIGINT — forcing cancellation.", isError: true); + ForceTerminateCancelledProcess(process, linkedCts); + } } + catch (ObjectDisposedException) { } }); } catch (Exception ex) { _logger.LogWarning($"Failed to send cancel signal: {ex.Message}"); - // If we can't signal, force cancel so we don't hang - _linkedCts?.Cancel(); + ForceTerminateCancelledProcess(process, linkedCts); + } + } + + private void ForceTerminateCancelledProcess( + Process process, + CancellationTokenSource? linkedCts) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to terminate cancelled process: {ex.Message}"); + } + finally + { + linkedCts?.Cancel(); } } @@ -637,7 +668,7 @@ public void Kill() public string GetFullOutput() { - return _outputBuilder.ToString(); + return GetOutputSnapshot().Output; } public async Task SendInputAsync( @@ -707,24 +738,33 @@ await process.StandardInput private void OnOutput(string data, bool isError, bool isRaw = false) { - if (isError) - { - if (isRaw) - _errorBuilder.Append(data); - else - _errorBuilder.AppendLine(data); - } - else + lock (_outputLock) { - if (isRaw) - _outputBuilder.Append(data); + if (isError) + { + if (isRaw) + _errorBuilder.Append(data); + else + _errorBuilder.AppendLine(data); + } else - _outputBuilder.AppendLine(data); + { + if (isRaw) + _outputBuilder.Append(data); + else + _outputBuilder.AppendLine(data); + } } OutputReceived?.Invoke(this, new ProcessOutputEventArgs(data, isError, isRaw)); } + private (string Output, string Error) GetOutputSnapshot() + { + lock (_outputLock) + return (_outputBuilder.ToString(), _errorBuilder.ToString()); + } + // P/Invoke for sending signals on Unix [DllImport("libc", SetLastError = true)] private static extern int kill(int pid, int sig); diff --git a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs b/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs deleted file mode 100644 index 30319ca9..00000000 --- a/src/MauiSherpa/Services/ProfilingSessionRunnerService.cs +++ /dev/null @@ -1,927 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; - -namespace MauiSherpa.Services; - -public class ProfilingSessionRunnerService : IProfilingSessionRunner -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILoggingService _logger; - private readonly Dictionary _stepProcesses = new(); - private readonly Dictionary _stepLogWriters = new(); - private readonly List _steps = new(); - private ProfilingPipelineState _state = ProfilingPipelineState.NotStarted; - private CancellationTokenSource? _cts; - private ProfilingCapturePlan? _plan; - private string? _outputDirectory; - private DateTime _startTime; - private volatile bool _stopRequested; - private int _gcDumpCount; - private int _traceCount; - private TaskCompletionSource _stopTcs = new(); - - public ProfilingPipelineState State => _state; - public IReadOnlyList Steps => _steps; - - public event EventHandler? PipelineStateChanged; - public event EventHandler? StepStateChanged; - public event EventHandler? StepOutputReceived; - - public ProfilingSessionRunnerService(IServiceProvider serviceProvider, ILoggingService logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - - public async Task RunAsync(ProfilingCapturePlan plan, CancellationToken ct = default) - { - _plan = plan; - _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _startTime = DateTime.Now; - _stopRequested = false; - _gcDumpCount = 0; - _traceCount = 0; - _stopTcs = new TaskCompletionSource(); - - if (!string.IsNullOrWhiteSpace(plan.OutputDirectory)) - Directory.CreateDirectory(plan.OutputDirectory); - - _outputDirectory = plan.OutputDirectory; - - _steps.Clear(); - _stepProcesses.Clear(); - foreach (var cmd in plan.Commands) - { - _steps.Add(new ProfilingStepStatus - { - StepId = cmd.Id, - DisplayName = cmd.DisplayName, - Kind = cmd.Kind, - IsLongRunning = cmd.IsLongRunning, - CanRunParallel = cmd.CanRunParallel, - StopTrigger = cmd.StopTrigger - }); - } - - SetPipelineState(ProfilingPipelineState.Running); - - try - { - await ExecutePipelineAsync(plan.Commands, _cts.Token); - - SetPipelineState(ProfilingPipelineState.Completing); - FlushAllStepLogs(); - var (found, missing) = CollectArtifacts(plan); - - // Post-process: convert any .nettrace files to speedscope format - found = await ConvertTraceArtifactsAsync(found, _cts.Token); - - var finalState = _steps.Any(s => s.State == ProfilingStepState.Failed) - ? ProfilingPipelineState.Failed - : ProfilingPipelineState.Completed; - SetPipelineState(finalState); - - return new ProfilingPipelineResult( - Success: finalState == ProfilingPipelineState.Completed, - TotalDuration: DateTime.Now - _startTime, - FinalState: finalState, - StepResults: _steps.ToList(), - ArtifactPaths: found, - MissingArtifacts: missing); - } - catch (OperationCanceledException) - { - SetPipelineState(ProfilingPipelineState.Cancelled); - FlushAllStepLogs(); - KillAllProcesses(); - return new ProfilingPipelineResult( - Success: false, - TotalDuration: DateTime.Now - _startTime, - FinalState: ProfilingPipelineState.Cancelled, - StepResults: _steps.ToList(), - ArtifactPaths: Array.Empty(), - MissingArtifacts: Array.Empty()); - } - catch (Exception ex) - { - if (_stopRequested) - { - // Stop was requested — failures during shutdown are expected - _logger.LogInformation("Pipeline stopped by user. Collecting available artifacts."); - SetPipelineState(ProfilingPipelineState.Completing); - FlushAllStepLogs(); - var (stopFound, stopMissing) = CollectArtifacts(plan); - IReadOnlyList convertedStopFound = await ConvertTraceArtifactsAsync(stopFound, CancellationToken.None); - SetPipelineState(ProfilingPipelineState.Completed); - return new ProfilingPipelineResult( - Success: true, - TotalDuration: DateTime.Now - _startTime, - FinalState: ProfilingPipelineState.Completed, - StepResults: _steps.ToList(), - ArtifactPaths: convertedStopFound, - MissingArtifacts: stopMissing); - } - - _logger.LogError($"Pipeline failed: {ex.Message}", ex); - SetPipelineState(ProfilingPipelineState.Failed); - FlushAllStepLogs(); - KillAllProcesses(); - return new ProfilingPipelineResult( - Success: false, - TotalDuration: DateTime.Now - _startTime, - FinalState: ProfilingPipelineState.Failed, - StepResults: _steps.ToList(), - ArtifactPaths: Array.Empty(), - MissingArtifacts: Array.Empty()); - } - } - - private async Task ExecutePipelineAsync(IReadOnlyList commands, CancellationToken ct) - { - var remaining = new HashSet(commands.Select(c => c.Id)); - var completed = new HashSet(); - var longRunningTasks = new Dictionary(); - - while (remaining.Count > 0) - { - ct.ThrowIfCancellationRequested(); - - // Find steps whose dependencies are all satisfied. - // Long-running steps (like dotnet-trace) can proceed when their dependency - // is merely started, because they themselves wait for the app to connect. - // Non-long-running steps (like dotnet-gcdump) must wait until their - // long-running dependency signals IsReady (app is actually connected). - var ready = commands - .Where(c => remaining.Contains(c.Id)) - .Where(c => c.DependsOn is null || c.DependsOn.All(dep => - completed.Contains(dep) || IsDependencySatisfied(dep, c.IsLongRunning))) - .ToList(); - - if (ready.Count == 0) - { - if (longRunningTasks.Count > 0) - { - // Wait briefly then re-check — a long-running step may signal - // IsReady at any time via its output, unblocking dependent steps. - var completedTask = await Task.WhenAny( - Task.WhenAny(longRunningTasks.Values), - Task.Delay(500, ct)); - - foreach (var (id, task) in longRunningTasks.ToList()) - { - if (task.IsCompleted) - { - completed.Add(id); - remaining.Remove(id); - longRunningTasks.Remove(id); - } - } - continue; - } - throw new InvalidOperationException( - $"Pipeline deadlock: steps {string.Join(", ", remaining)} have unresolved dependencies."); - } - - var parallelSteps = ready.Where(c => c.CanRunParallel).ToList(); - var sequentialSteps = ready.Where(c => !c.CanRunParallel).ToList(); - - // Launch parallel steps - var parallelTasks = new List(); - foreach (var step in parallelSteps) - { - remaining.Remove(step.Id); - var task = LaunchStepAsync(step, ct); - - if (step.IsLongRunning) - { - longRunningTasks[step.Id] = task; - await Task.Delay(500, ct); - } - else - { - var stepId = step.Id; - parallelTasks.Add(task.ContinueWith(_ => - { - completed.Add(stepId); - }, TaskContinuationOptions.OnlyOnRanToCompletion)); - } - } - - // Run sequential steps one at a time - foreach (var step in sequentialSteps) - { - remaining.Remove(step.Id); - var task = LaunchStepAsync(step, ct); - - if (step.IsLongRunning) - { - longRunningTasks[step.Id] = task; - await Task.Delay(500, ct); - } - else - { - await task; - completed.Add(step.Id); - } - } - - if (parallelTasks.Count > 0) - await Task.WhenAll(parallelTasks); - } - - // If long-running ManualStop steps remain, wait for StopCapture() - var manualStopSteps = longRunningTasks.Keys - .Select(id => commands.First(c => c.Id == id)) - .Where(c => c.StopTrigger == ProfilingStopTrigger.ManualStop) - .ToList(); - - if (manualStopSteps.Count > 0) - { - SetPipelineState(ProfilingPipelineState.WaitingForStop); - await Task.WhenAll(longRunningTasks.Values); - } - else if (longRunningTasks.Count > 0) - { - // No ManualStop steps, but long-running infrastructure steps (dsrouter, build-and-run) - // are still running. Enter WaitingForStop so the user can perform on-demand actions - // (Start Trace, Collect GC Dump) before choosing to stop. - SetPipelineState(ProfilingPipelineState.WaitingForStop); - await _stopTcs.Task; - } - - // Stop any OnPipelineStop steps - foreach (var (id, task) in longRunningTasks.ToList()) - { - var step = commands.First(c => c.Id == id); - if (step.StopTrigger == ProfilingStopTrigger.OnPipelineStop - && _stepProcesses.TryGetValue(id, out var proc)) - { - proc.Cancel(); - await task; - } - } - } - - /// - /// Checks if a dependency is satisfied for a dependent step. - /// Long-running dependents (e.g. dotnet-trace) can proceed when the dependency - /// is just started — they themselves wait for the app. Non-long-running dependents - /// (e.g. dotnet-gcdump) must wait for IsReady, meaning the dependency has - /// established its connection and the app is actually available. - /// - private bool IsDependencySatisfied(string depStepId, bool dependentIsLongRunning) - { - var status = _steps.FirstOrDefault(s => s.StepId == depStepId); - if (status is null) return false; - if (!status.IsLongRunning) return false; - if (status.State != ProfilingStepState.Running) return false; - - // Long-running dependents can proceed as soon as the dep is started - if (dependentIsLongRunning) return true; - - // Non-long-running dependents must wait for the dep to signal readiness - return status.IsReady; - } - - private async Task LaunchStepAsync(ProfilingCommandStep step, CancellationToken ct) - { - var status = _steps.First(s => s.StepId == step.Id); - - if (step.IsOptional && step.RequiredRuntimeBindings?.Count > 0) - { - SetStepState(status, ProfilingStepState.Skipped); - return; - } - - SetStepState(status, ProfilingStepState.Running); - status.StartedAt = DateTime.Now; - - var processService = _serviceProvider.GetRequiredService(); - _stepProcesses[step.Id] = processService; - - // Open a log file for this step's output - var logWriter = OpenStepLogWriter(step.Id, step.DisplayName, step.Command, step.Arguments); - - processService.OutputReceived += (_, e) => - { - var line = new ProfilingStepOutputLine(e.Data, e.IsError, DateTime.Now); - status.OutputLines.Add(line); - WriteToStepLog(logWriter, e.Data, e.IsError); - StepOutputReceived?.Invoke(this, new ProfilingStepOutputEventArgs - { - StepId = step.Id, - Text = e.Data, - IsError = e.IsError - }); - - // Detect readiness for long-running steps by matching output patterns - if (step.IsLongRunning && !status.IsReady && !e.IsError - && step.ReadyOutputPattern is not null - && e.Data.Contains(step.ReadyOutputPattern, StringComparison.OrdinalIgnoreCase)) - { - status.IsReady = true; - _logger.LogDebug($"Step '{step.Id}' is ready (matched: {step.ReadyOutputPattern})"); - } - }; - - try - { - var request = step.ToProcessRequest(); - var result = await processService.ExecuteAsync(request, ct); - - status.ExitCode = result.ExitCode; - status.ProcessId = processService.ProcessId; - status.CompletedAt = DateTime.Now; - status.Duration = status.CompletedAt.Value - status.StartedAt.Value; - - if (result.Success) - { - SetStepState(status, ProfilingStepState.Completed); - } - else if (result.WasCancelled || _stopRequested) - { - // Step was cancelled or stop was requested — treat as stopped, not failed - if (status.State != ProfilingStepState.Stopped) - SetStepState(status, ProfilingStepState.Stopped); - } - else if (step.IsLongRunning && status.State == ProfilingStepState.Stopped) - { - // Long-running step was manually stopped — non-zero exit is expected - } - else if (step.IsOptional) - { - SetStepState(status, ProfilingStepState.Skipped); - status.ErrorMessage = result.Error; - } - else - { - SetStepState(status, ProfilingStepState.Failed); - status.ErrorMessage = result.Error; - throw new InvalidOperationException( - $"Required step '{step.DisplayName}' failed with exit code {result.ExitCode}: {result.Error}"); - } - } - catch (OperationCanceledException) - { - status.CompletedAt = DateTime.Now; - status.Duration = status.CompletedAt.Value - (status.StartedAt ?? DateTime.Now); - SetStepState(status, ProfilingStepState.Cancelled); - throw; - } - catch (Exception ex) when (ex is not InvalidOperationException) - { - status.CompletedAt = DateTime.Now; - status.Duration = status.CompletedAt.Value - (status.StartedAt ?? DateTime.Now); - status.ErrorMessage = ex.Message; - SetStepState(status, ProfilingStepState.Failed); - if (!step.IsOptional) - throw; - } - } - - public async Task StopCaptureAsync() - { - _stopRequested = true; - _logger.LogInformation("StopCapture requested — sending SIGINT to all running processes"); - - // Signal the WaitingForStop await so the pipeline can proceed to Completing - _stopTcs.TrySetResult(); - - // Send SIGINT to all running steps. Cancel() sends SIGINT but does NOT - // immediately cancel the CTS, so WaitForExitAsync in LaunchStepAsync will - // block until the process actually exits and flushes its output files. - foreach (var status in _steps.Where(s => s.State == ProfilingStepState.Running)) - { - SetStepState(status, ProfilingStepState.Stopped); - if (_stepProcesses.TryGetValue(status.StepId, out var proc)) - { - proc.Cancel(); - } - } - - // The pipeline's ExecutePipelineAsync is awaiting Task.WhenAll on long-running - // tasks. Those tasks will complete once processes exit after SIGINT. We just - // need to return and let the pipeline flow continue naturally. - await Task.CompletedTask; - } - - public void Cancel() - { - _logger.LogInformation("Pipeline cancel requested — killing all processes"); - _cts?.Cancel(); - KillAllProcesses(); - } - - private void KillAllProcesses() - { - foreach (var (stepId, proc) in _stepProcesses) - { - try - { - if (proc.CurrentState == ProcessState.Running) - proc.Kill(); - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to kill process for step {stepId}: {ex.Message}"); - } - } - } - - private (IReadOnlyList found, IReadOnlyList missing) CollectArtifacts(ProfilingCapturePlan plan) - { - var found = new List(); - var missing = new List(); - - foreach (var artifact in plan.ExpectedArtifacts) - { - // RelativePath already includes the output directory (e.g., "artifacts/profiling/proj/date-1/trace.nettrace") - // FileName is just the basename (e.g., "trace.nettrace") - // Use RelativePath as-is, fall back to combining FileName with OutputDirectory - string path; - if (!string.IsNullOrWhiteSpace(artifact.RelativePath)) - { - path = Path.IsPathRooted(artifact.RelativePath) - ? artifact.RelativePath - : Path.GetFullPath(artifact.RelativePath); - } - else if (!string.IsNullOrWhiteSpace(artifact.FileName)) - { - path = Path.GetFullPath(Path.Combine(plan.OutputDirectory, artifact.FileName)); - } - else - { - continue; - } - - if (File.Exists(path)) - found.Add(path); - else - missing.Add(path); - } - - // Scan for on-demand artifacts that aren't in the expected list. - // On-demand gcdumps (memory-1.gcdump) and traces (trace-1.nettrace) use numbered - // filenames that don't match the plan's expected artifact names. - if (Directory.Exists(plan.OutputDirectory)) - { - var foundSet = new HashSet(found, StringComparer.OrdinalIgnoreCase); - foreach (var pattern in new[] { "*.gcdump", "*.nettrace", "*.speedscope.json" }) - { - foreach (var file in Directory.GetFiles(plan.OutputDirectory, pattern)) - { - var fullPath = Path.GetFullPath(file); - if (!foundSet.Contains(fullPath)) - { - found.Add(fullPath); - foundSet.Add(fullPath); - } - } - } - } - - return (found, missing); - } - - private void SetPipelineState(ProfilingPipelineState newState) - { - var old = _state; - _state = newState; - _logger.LogInformation($"Pipeline state: {old} → {newState}"); - PipelineStateChanged?.Invoke(this, new ProfilingPipelineStateChangedEventArgs - { - OldState = old, - NewState = newState - }); - } - - private void SetStepState(ProfilingStepStatus status, ProfilingStepState newState) - { - var old = status.State; - status.State = newState; - StepStateChanged?.Invoke(this, new ProfilingStepStateChangedEventArgs - { - StepId = status.StepId, - OldState = old, - NewState = newState - }); - } - - public async Task CollectGcDumpAsync(CancellationToken ct = default) - { - if (_plan is null || _outputDirectory is null || _state != ProfilingPipelineState.WaitingForStop) - { - _logger.LogWarning("Cannot collect GC dump: pipeline is not in WaitingForStop state."); - return null; - } - - var dumpNumber = Interlocked.Increment(ref _gcDumpCount); - var fileName = $"memory-{dumpNumber}.gcdump"; - var outputPath = Path.Combine(_outputDirectory, fileName); - - // Build arguments: reuse the diagnostic connection info from the running plan - var arguments = new List { "collect" }; - var diagnostics = _plan.Diagnostics; - var options = _plan.Options; - - if (diagnostics?.IpcAddress is not null) - { - // Standalone dsrouter mode — connect via IPC - arguments.Add("--diagnostic-port"); - arguments.Add($"{diagnostics.IpcAddress},connect"); - } - else - { - // Desktop mode — use process ID discovered at runtime - var discoveredPid = _steps - .FirstOrDefault(s => s.StepId == "discover-process-id") - ?.OutputLines - .LastOrDefault(l => !l.IsError) - ?.Text; - - // Try to get PID from the build-and-run step's process - var buildStep = _stepProcesses.GetValueOrDefault("build-and-run"); - var pid = options.ProcessId - ?? (discoveredPid is not null && int.TryParse(discoveredPid.Trim(), out var parsed) ? parsed : (int?)null) - ?? buildStep?.ProcessId; - - if (pid is null) - { - _logger.LogWarning("Cannot collect GC dump: no process ID available."); - Interlocked.Decrement(ref _gcDumpCount); - return null; - } - - arguments.Add("--process-id"); - arguments.Add(pid.ToString()!); - } - - arguments.Add("-o"); - arguments.Add(outputPath); - - var stepId = $"gcdump-{dumpNumber}"; - var step = new ProfilingCommandStep( - Id: stepId, - Kind: ProfilingCommandStepKind.CollectArtifacts, - DisplayName: $"GC dump #{dumpNumber}", - Description: $"On-demand heap snapshot #{dumpNumber}.", - Command: "dotnet-gcdump", - Arguments: arguments, - WorkingDirectory: options.WorkingDirectory, - IsLongRunning: false, - RequiresManualStop: false, - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet-gcdump", - ["output"] = outputPath - }); - - var status = new ProfilingStepStatus - { - StepId = stepId, - DisplayName = step.DisplayName, - Kind = step.Kind, - IsLongRunning = false, - CanRunParallel = false, - StopTrigger = ProfilingStopTrigger.None - }; - _steps.Add(status); - StepStateChanged?.Invoke(this, new ProfilingStepStateChangedEventArgs - { - StepId = stepId, - OldState = ProfilingStepState.Pending, - NewState = ProfilingStepState.Pending - }); - - try - { - await LaunchStepAsync(step, ct); - return File.Exists(outputPath) ? outputPath : null; - } - catch (Exception ex) - { - _logger.LogWarning($"On-demand GC dump failed: {ex.Message}"); - return null; - } - } - - private string? _activeTraceStepId; - private Task? _activeTraceTask; - - /// - /// Whether a trace capture is currently active. - /// - public bool IsTraceActive => _activeTraceStepId is not null - && _steps.Any(s => s.StepId == _activeTraceStepId && s.State == ProfilingStepState.Running); - - /// - /// Start an on-demand trace capture. Returns the step ID or null if it cannot start. - /// The trace runs until StopTraceAsync() is called. - /// - public string? StartTraceAsync() - { - if (_plan is null || _outputDirectory is null || _state != ProfilingPipelineState.WaitingForStop) - { - _logger.LogWarning("Cannot start trace: pipeline is not in WaitingForStop state."); - return null; - } - - if (IsTraceActive) - { - _logger.LogWarning("Cannot start trace: a trace is already running."); - return null; - } - - var traceNumber = Interlocked.Increment(ref _traceCount); - var fileName = traceNumber == 1 ? "trace.nettrace" : $"trace-{traceNumber}.nettrace"; - var outputPath = Path.Combine(_outputDirectory, fileName); - - var arguments = new List { "collect" }; - var diagnostics = _plan.Diagnostics; - var options = _plan.Options; - - if (diagnostics?.IpcAddress is not null) - { - arguments.Add("--diagnostic-port"); - arguments.Add($"{diagnostics.IpcAddress},connect"); - } - else - { - var discoveredPid = _steps - .FirstOrDefault(s => s.StepId == "discover-process-id") - ?.OutputLines - .LastOrDefault(l => !l.IsError) - ?.Text; - - var buildStep = _stepProcesses.GetValueOrDefault("build-and-run"); - var pid = options.ProcessId - ?? (discoveredPid is not null && int.TryParse(discoveredPid.Trim(), out var parsed) ? parsed : (int?)null) - ?? buildStep?.ProcessId; - - if (pid is null) - { - _logger.LogWarning("Cannot start trace: no process ID available."); - Interlocked.Decrement(ref _traceCount); - return null; - } - - arguments.Add("--process-id"); - arguments.Add(pid.ToString()!); - } - - arguments.Add("--output"); - arguments.Add(outputPath); - - // Add profiling profiles based on capture kinds - var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var kind in _plan.Session.CaptureKinds) - { - switch (kind) - { - case ProfilingCaptureKind.Cpu: - case ProfilingCaptureKind.Startup: - profiles.Add("dotnet-sampled-thread-time"); - break; - case ProfilingCaptureKind.Rendering: - case ProfilingCaptureKind.Network: - case ProfilingCaptureKind.Energy: - case ProfilingCaptureKind.SystemTrace: - profiles.Add("dotnet-common"); - break; - } - } - if (profiles.Count == 0) - profiles.Add("dotnet-sampled-thread-time"); - arguments.Add("--profile"); - arguments.Add(string.Join(",", profiles)); - - // Add JIT/Loader provider flags for managed symbol resolution in speedscope. - // 0x10000018 = JitTracing | NGenTracing | Loader keywords, Verbose level (5). - arguments.Add("--providers"); - arguments.Add("Microsoft-Windows-DotNETRuntime:0x10000018:5"); - - var stepId = $"capture-trace-{traceNumber}"; - _activeTraceStepId = stepId; - var step = new ProfilingCommandStep( - Id: stepId, - Kind: ProfilingCommandStepKind.Capture, - DisplayName: traceNumber == 1 ? "Collect trace" : $"Collect trace #{traceNumber}", - Description: "On-demand trace capture.", - Command: "dotnet-trace", - Arguments: arguments, - WorkingDirectory: options.WorkingDirectory, - IsLongRunning: true, - RequiresManualStop: true, - CanRunParallel: false, - StopTrigger: ProfilingStopTrigger.ManualStop, - ReadyOutputPattern: "Process", - Metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["tool"] = "dotnet-trace", - ["output"] = outputPath - }); - - var status = new ProfilingStepStatus - { - StepId = stepId, - DisplayName = step.DisplayName, - Kind = step.Kind, - IsLongRunning = true, - CanRunParallel = false, - StopTrigger = ProfilingStopTrigger.ManualStop - }; - _steps.Add(status); - StepStateChanged?.Invoke(this, new ProfilingStepStateChangedEventArgs - { - StepId = stepId, - OldState = ProfilingStepState.Pending, - NewState = ProfilingStepState.Pending - }); - - _activeTraceTask = Task.Run(async () => - { - try - { - await LaunchStepAsync(step, _cts?.Token ?? CancellationToken.None); - } - catch (Exception ex) - { - _logger.LogWarning($"Trace capture failed: {ex.Message}"); - } - }); - - return stepId; - } - - /// - /// Stop the currently running on-demand trace. - /// - public async Task StopTraceAsync() - { - if (_activeTraceStepId is null) - { - _logger.LogWarning("No active trace to stop."); - return; - } - - var stepId = _activeTraceStepId; - _logger.LogInformation("Stopping on-demand trace capture..."); - - var status = _steps.FirstOrDefault(s => s.StepId == stepId); - if (status is not null) - SetStepState(status, ProfilingStepState.Stopped); - - if (_stepProcesses.TryGetValue(stepId, out var proc)) - { - _logger.LogInformation("Sending cancel signal to process"); - proc.Cancel(); - } - - if (_activeTraceTask is not null) - { - try - { - await _activeTraceTask.WaitAsync(TimeSpan.FromSeconds(30)); - } - catch (TimeoutException) - { - _logger.LogWarning("Trace process did not exit within timeout."); - } - } - - _activeTraceStepId = null; - _activeTraceTask = null; - } - - public void Dispose() - { - _cts?.Cancel(); - _cts?.Dispose(); - FlushAllStepLogs(); - KillAllProcesses(); - _stepProcesses.Clear(); - } - - private StreamWriter? OpenStepLogWriter(string stepId, string displayName, string command, IReadOnlyList? arguments) - { - if (string.IsNullOrWhiteSpace(_outputDirectory)) - return null; - - try - { - var logPath = Path.Combine(_outputDirectory, $"{stepId}.log"); - var writer = new StreamWriter(logPath, append: false) { AutoFlush = true }; - writer.WriteLine($"# {displayName}"); - writer.WriteLine($"# Command: {command} {(arguments is not null ? string.Join(" ", arguments) : "")}"); - writer.WriteLine($"# Started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); - writer.WriteLine(); - _stepLogWriters[stepId] = writer; - return writer; - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to create log file for step '{stepId}': {ex.Message}"); - return null; - } - } - - private static void WriteToStepLog(StreamWriter? writer, string text, bool isError) - { - if (writer is null) - return; - - try - { - var prefix = isError ? "[ERR] " : ""; - writer.WriteLine($"{prefix}{text}"); - } - catch - { - // Don't let log writing failures break the pipeline - } - } - - private void FlushAllStepLogs() - { - foreach (var (stepId, writer) in _stepLogWriters) - { - try - { - var status = _steps.FirstOrDefault(s => s.StepId == stepId); - if (status is not null) - { - writer.WriteLine(); - writer.WriteLine($"# Finished: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); - writer.WriteLine($"# State: {status.State}"); - writer.WriteLine($"# Exit code: {status.ExitCode?.ToString() ?? "N/A"}"); - if (status.Duration.HasValue) - writer.WriteLine($"# Duration: {status.Duration.Value.TotalSeconds:F1}s"); - if (!string.IsNullOrWhiteSpace(status.ErrorMessage)) - writer.WriteLine($"# Error: {status.ErrorMessage}"); - } - - writer.Flush(); - writer.Dispose(); - } - catch - { - // Best effort - } - } - - _stepLogWriters.Clear(); - } - - /// - /// Post-processes trace artifacts: converts any .nettrace files to .speedscope.json - /// and adds the converted files to the artifact list. - /// - private async Task> ConvertTraceArtifactsAsync( - IReadOnlyList artifacts, CancellationToken ct) - { - var converter = _serviceProvider.GetService(); - if (converter is null) - { - _logger.LogWarning("No IProfilingArtifactConverterService registered — skipping trace conversion"); - return artifacts; - } - - var result = new List(artifacts); - - foreach (var artifact in artifacts) - { - if (!artifact.EndsWith(".nettrace", StringComparison.OrdinalIgnoreCase)) - continue; - - // dotnet-trace collect --format Speedscope already emits a .speedscope.json - // alongside the .nettrace — skip conversion if it already exists. - var baseName = artifact[..^".nettrace".Length]; - var existingSpeedscope = $"{baseName}.speedscope.json"; - if (File.Exists(existingSpeedscope)) - { - _logger.LogInformation($"Speedscope file already exists from capture: {Path.GetFileName(existingSpeedscope)}"); - if (!result.Contains(existingSpeedscope)) - result.Add(existingSpeedscope); - continue; - } - - try - { - _logger.LogInformation($"Converting {Path.GetFileName(artifact)} to speedscope format..."); - var converted = await converter.ConvertToSpeedscopeAsync(artifact, ct); - if (converted is not null && !result.Contains(converted)) - { - result.Add(converted); - _logger.LogInformation($"Speedscope file added: {Path.GetFileName(converted)}"); - } - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to convert {artifact} to speedscope: {ex.Message}"); - } - } - - return result; - } -} diff --git a/src/MauiSherpa/Services/ProfilingSessionStorageService.cs b/src/MauiSherpa/Services/ProfilingSessionStorageService.cs deleted file mode 100644 index 723ca17b..00000000 --- a/src/MauiSherpa/Services/ProfilingSessionStorageService.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.IO.Compression; -using System.Text.Json; -using System.Text.Json.Serialization; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Services; - -namespace MauiSherpa.Services; - -/// -/// Manages persistent profiling sessions stored under AppDataPath/profiling/. -/// Each session is a folder containing session.json + artifact files. -/// -public class ProfilingSessionStorageService : IProfilingSessionStorageService -{ - private readonly string _profilingRoot; - private readonly ILoggingService _logger; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } - }; - - private const string ManifestFileName = "session.json"; - - public ProfilingSessionStorageService(ILoggingService logger) - { - _logger = logger; - _profilingRoot = Path.Combine(AppDataPath.GetAppDataDirectory(), "profiling"); - Directory.CreateDirectory(_profilingRoot); - } - - public async Task> GetSessionsAsync(CancellationToken ct = default) - { - var sessions = new List(); - - if (!Directory.Exists(_profilingRoot)) - return sessions; - - foreach (var dir in Directory.GetDirectories(_profilingRoot)) - { - ct.ThrowIfCancellationRequested(); - var manifestPath = Path.Combine(dir, ManifestFileName); - if (!File.Exists(manifestPath)) - continue; - - try - { - var manifest = await ReadManifestAsync(manifestPath, ct); - if (manifest is not null) - { - manifest.DirectoryPath = dir; - sessions.Add(manifest); - } - } - catch (Exception ex) - { - _logger.LogWarning($"Failed to read session manifest at {manifestPath}: {ex.Message}"); - } - } - - // Most recent first - sessions.Sort((a, b) => b.CreatedAt.CompareTo(a.CreatedAt)); - return sessions; - } - - public async Task GetSessionAsync(string sessionId, CancellationToken ct = default) - { - var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); - var manifestPath = Path.Combine(dir, ManifestFileName); - - if (!File.Exists(manifestPath)) - return null; - - var manifest = await ReadManifestAsync(manifestPath, ct); - if (manifest is not null) - manifest.DirectoryPath = dir; - return manifest; - } - - public async Task SaveSessionAsync(ProfilingSessionManifest manifest, CancellationToken ct = default) - { - var dir = GetSessionDirectoryPath(manifest.Id); - var manifestPath = Path.Combine(dir, ManifestFileName); - - // Update artifact sizes from disk - foreach (var artifact in manifest.Artifacts) - { - var artifactPath = Path.Combine(dir, artifact.FileName); - if (File.Exists(artifactPath)) - { - var info = new FileInfo(artifactPath); - // Use reflection-free approach: create new record with updated size - if (artifact.SizeBytes is null || artifact.SizeBytes == 0) - { - var idx = manifest.Artifacts.IndexOf(artifact); - if (idx >= 0) - { - manifest.Artifacts[idx] = artifact with { SizeBytes = info.Length }; - } - } - } - } - - manifest.DirectoryPath = dir; - - var json = JsonSerializer.Serialize(manifest, JsonOptions); - await File.WriteAllTextAsync(manifestPath, json, ct); - - _logger.LogInformation($"Session manifest saved: {manifest.Id}"); - } - - public Task DeleteSessionAsync(string sessionId, CancellationToken ct = default) - { - var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); - - if (Directory.Exists(dir)) - { - Directory.Delete(dir, recursive: true); - _logger.LogInformation($"Session deleted: {sessionId}"); - } - - return Task.CompletedTask; - } - - public string GetSessionDirectoryPath(string sessionId) - { - var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); - Directory.CreateDirectory(dir); - return dir; - } - - public string GenerateSessionId(string? projectName = null) - { - var datePart = DateTime.Now.ToString("yyyy-MM-dd"); - var namePart = SanitizePath(projectName ?? "session"); - var baseName = $"{datePart}_{namePart}"; - - // Find next available run number - var runNumber = 1; - while (Directory.Exists(Path.Combine(_profilingRoot, $"{baseName}_{runNumber}"))) - { - runNumber++; - } - - return $"{baseName}_{runNumber}"; - } - - public async Task ExportSessionAsync(string sessionId, string outputZipPath, CancellationToken ct = default) - { - var dir = Path.Combine(_profilingRoot, SanitizePath(sessionId)); - - if (!Directory.Exists(dir)) - throw new DirectoryNotFoundException($"Session directory not found: {dir}"); - - // Delete existing zip if present (save dialog may have created empty file) - if (File.Exists(outputZipPath)) - File.Delete(outputZipPath); - - await Task.Run(() => ZipFile.CreateFromDirectory(dir, outputZipPath), ct); - _logger.LogInformation($"Session exported: {sessionId} → {outputZipPath}"); - } - - public async Task ImportSessionAsync(string zipPath, CancellationToken ct = default) - { - if (!File.Exists(zipPath)) - return null; - - // Extract to a temp directory first to read manifest - var tempDir = Path.Combine(Path.GetTempPath(), $"sherpa-import-{Guid.NewGuid():N}"); - try - { - await Task.Run(() => ZipFile.ExtractToDirectory(zipPath, tempDir), ct); - - var manifestPath = Path.Combine(tempDir, ManifestFileName); - if (!File.Exists(manifestPath)) - { - _logger.LogWarning($"Imported zip has no {ManifestFileName}"); - return null; - } - - var manifest = await ReadManifestAsync(manifestPath, ct); - if (manifest is null) - return null; - - // Move to managed location (use a new ID if collision) - var targetDir = Path.Combine(_profilingRoot, SanitizePath(manifest.Id)); - if (Directory.Exists(targetDir)) - { - // Generate new ID to avoid collision - var newId = GenerateSessionId(manifest.Name); - targetDir = Path.Combine(_profilingRoot, SanitizePath(newId)); - // We don't change manifest.Id since the record is immutable — just store under new folder - } - - Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); - - // Move the extracted folder to the managed location - if (Directory.Exists(targetDir)) - Directory.Delete(targetDir, true); - Directory.Move(tempDir, targetDir); - - manifest.DirectoryPath = targetDir; - _logger.LogInformation($"Session imported: {manifest.Id} from {zipPath}"); - return manifest; - } - catch (Exception ex) - { - _logger.LogError($"Failed to import session from {zipPath}: {ex.Message}", ex); - return null; - } - finally - { - // Clean up temp directory if it still exists - if (Directory.Exists(tempDir)) - { - try { Directory.Delete(tempDir, true); } - catch { /* best effort */ } - } - } - } - - private static async Task ReadManifestAsync(string path, CancellationToken ct) - { - await using var stream = File.OpenRead(path); - return await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); - } - - private static string SanitizePath(string input) - { - var invalid = Path.GetInvalidFileNameChars(); - var sanitized = new char[input.Length]; - for (int i = 0; i < input.Length; i++) - { - sanitized[i] = Array.IndexOf(invalid, input[i]) >= 0 ? '_' : input[i]; - } - return new string(sanitized).Trim('.'); - } -} diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs index 3b949306..5c3ae6e3 100644 --- a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCapabilitiesHandlerTests.cs @@ -25,27 +25,27 @@ public GetProfilingCapabilitiesHandlerTests() public async Task Handle_ReturnsCapabilitiesForRequestedPlatform() { var expectedCapabilities = new ProfilingPlatformCapabilities( - ProfilingTargetPlatform.MacCatalyst, - "Mac Catalyst", - [ProfilingTargetKind.Desktop], - [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory], - [ProfilingArtifactKind.Trace], + ProfilingTargetPlatform.iOS, + "iOS Simulator", + [ProfilingTargetKind.Simulator], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Interaction], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Mibc], [ProfilingScenarioKind.Launch, ProfilingScenarioKind.Interaction], SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, + SupportsAttachToProcess: false, + SupportsLiveMetrics: false, SupportsSymbolication: true); - _profilingCatalogService.Setup(x => x.GetCapabilitiesAsync(ProfilingTargetPlatform.MacCatalyst, It.IsAny())) + _profilingCatalogService.Setup(x => x.GetCapabilitiesAsync(ProfilingTargetPlatform.iOS, It.IsAny())) .ReturnsAsync(expectedCapabilities); var result = await _handler.Handle( - new GetProfilingCapabilitiesRequest(ProfilingTargetPlatform.MacCatalyst), + new GetProfilingCapabilitiesRequest(ProfilingTargetPlatform.iOS), _context.Object, CancellationToken.None); result.Should().Be(expectedCapabilities); - _profilingCatalogService.Verify(x => x.GetCapabilitiesAsync(ProfilingTargetPlatform.MacCatalyst, It.IsAny()), Times.Once); + _profilingCatalogService.Verify(x => x.GetCapabilitiesAsync(ProfilingTargetPlatform.iOS, It.IsAny()), Times.Once); } [Fact] diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs index 449378d6..52aeaf4d 100644 --- a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs +++ b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingCatalogHandlerTests.cs @@ -29,18 +29,18 @@ [new ProfilingPlatformCapabilities( ProfilingTargetPlatform.Android, "Android", [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Emulator], - [ProfilingCaptureKind.Cpu], - [ProfilingArtifactKind.Trace], - [ProfilingScenarioKind.Launch], + [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Interaction], + [ProfilingArtifactKind.Trace, ProfilingArtifactKind.Mibc], + [ProfilingScenarioKind.Launch, ProfilingScenarioKind.Interaction], SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, + SupportsAttachToProcess: false, + SupportsLiveMetrics: false, SupportsSymbolication: false)], [new ProfilingScenarioDefinition( ProfilingScenarioKind.Launch, - "Launch & startup", + "Startup", "desc", - [ProfilingCaptureKind.Cpu], + [ProfilingCaptureKind.Startup], TimeSpan.FromMinutes(1))]); _profilingCatalogService.Setup(x => x.GetCatalogAsync(It.IsAny())) diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs deleted file mode 100644 index a855e6e1..00000000 --- a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/GetProfilingPrerequisitesHandlerTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using FluentAssertions; -using MauiSherpa.Core.Handlers.Profiling; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Requests.Profiling; -using Moq; -using Shiny.Mediator; - -namespace MauiSherpa.Core.Tests.Handlers.Profiling; - -public class GetProfilingPrerequisitesHandlerTests -{ - private readonly Mock _profilingPrerequisitesService = new(); - private readonly Mock _context = new(); - private readonly GetProfilingPrerequisitesHandler _handler; - - public GetProfilingPrerequisitesHandlerTests() - { - _handler = new GetProfilingPrerequisitesHandler(_profilingPrerequisitesService.Object); - } - - [Fact] - public async Task Handle_ReturnsPrerequisiteReport() - { - var requestedCaptureKinds = new[] { ProfilingCaptureKind.Cpu }; - var expectedReport = new ProfilingPrerequisiteReport( - new ProfilingPrerequisiteContext( - ProfilingTargetPlatform.Android, - requestedCaptureKinds, - "/tmp", - "/usr/local/share/dotnet/dotnet", - new DoctorContext("/tmp", "/usr/local/share/dotnet", null, null, null, "10.0.100")), - [ - new ProfilingPrerequisiteStatus( - "dotnet-trace", - ProfilingPrerequisiteKind.DotNetTool, - DependencyStatusType.Ok, - IsRequired: true, - RequiredVersion: "10.x", - RecommendedVersion: "10.x", - InstalledVersion: "10.0.41001", - Message: "Ready") - ], - DateTimeOffset.UtcNow); - - _profilingPrerequisitesService - .Setup(x => x.GetPrerequisitesAsync( - ProfilingTargetPlatform.Android, - It.Is>(kinds => kinds.SequenceEqual(requestedCaptureKinds)), - null, - It.IsAny())) - .ReturnsAsync(expectedReport); - - var result = await _handler.Handle( - new GetProfilingPrerequisitesRequest(ProfilingTargetPlatform.Android, requestedCaptureKinds), - _context.Object, - CancellationToken.None); - - result.Should().Be(expectedReport); - } - - [Fact] - public void GetKey_ReturnsStablePlatformAndCaptureKey() - { - var request = new GetProfilingPrerequisitesRequest( - ProfilingTargetPlatform.Android, - [ProfilingCaptureKind.Memory, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory]); - - request.GetKey().Should().Be("profiling:prerequisites:Android:Cpu,Memory"); - } -} diff --git a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs b/tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs deleted file mode 100644 index e5e108dc..00000000 --- a/tests/MauiSherpa.Core.Tests/Handlers/Profiling/PlanProfilingCaptureHandlerTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using FluentAssertions; -using MauiSherpa.Core.Handlers.Profiling; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Requests.Profiling; -using Moq; -using Shiny.Mediator; - -namespace MauiSherpa.Core.Tests.Handlers.Profiling; - -public class PlanProfilingCaptureHandlerTests -{ - private readonly Mock _profilingCaptureOrchestrationService = new(); - private readonly Mock _context = new(); - - [Fact] - public async Task Handle_ReturnsPlanFromOrchestrationService() - { - var session = new ProfilingSessionDefinition( - "session-1", - "Android launch", - new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.Emulator, - "emulator-5554", - "Pixel 8"), - ProfilingScenarioKind.Launch, - [ProfilingCaptureKind.Cpu], - CreatedAt: DateTimeOffset.UtcNow); - var request = new PlanProfilingCaptureRequest(session, new ProfilingCapturePlanOptions(ProjectPath: "/Users/test/App.csproj")); - var expectedPlan = new ProfilingCapturePlan( - session, - new ProfilingPlatformCapabilities( - ProfilingTargetPlatform.Android, - "Android", - [ProfilingTargetKind.PhysicalDevice, ProfilingTargetKind.Emulator], - [ProfilingCaptureKind.Cpu], - [ProfilingArtifactKind.Trace], - [ProfilingScenarioKind.Launch], - SupportsLaunchProfiling: true, - SupportsAttachToProcess: true, - SupportsLiveMetrics: true, - SupportsSymbolication: false), - request.Options!, - "macOS", - "net10.0-android", - "artifacts/profiling/session-1", - "/Users/test", - true, - null, - null, - new ProfilingPlanValidation([], []), - [], - [], - [], - new Dictionary()); - - _profilingCaptureOrchestrationService.Setup(service => - service.PlanCaptureAsync(session, request.Options, It.IsAny())) - .ReturnsAsync(expectedPlan); - - var handler = new PlanProfilingCaptureHandler(_profilingCaptureOrchestrationService.Object); - var result = await handler.Handle(request, _context.Object, CancellationToken.None); - - result.Should().Be(expectedPlan); - _profilingCaptureOrchestrationService.Verify(service => - service.PlanCaptureAsync(session, request.Options, It.IsAny()), Times.Once); - } -} diff --git a/tests/MauiSherpa.Core.Tests/Services/MauiCliExecutableResolverTests.cs b/tests/MauiSherpa.Core.Tests/Services/MauiCliExecutableResolverTests.cs new file mode 100644 index 00000000..cd5427d0 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/MauiCliExecutableResolverTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class MauiCliExecutableResolverTests +{ + [Fact] + public void Resolve_PrefersGlobalToolShim() + { + var root = Path.Combine(Path.GetTempPath(), $"maui-cli-resolver-{Guid.NewGuid():N}"); + var toolDirectory = Path.Combine(root, ".dotnet", "tools"); + Directory.CreateDirectory(toolDirectory); + var toolPath = Path.Combine(toolDirectory, "maui"); + File.WriteAllText(toolPath, string.Empty); + + try + { + var result = MauiCliExecutableResolver.Resolve( + root, + pathEnvironment: string.Empty, + isWindows: false); + + result.Should().Be(toolPath); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Resolve_FallsBackToPath() + { + var root = Path.Combine(Path.GetTempPath(), $"maui-cli-path-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + var toolPath = Path.Combine(root, "maui.exe"); + File.WriteAllText(toolPath, string.Empty); + + try + { + var result = MauiCliExecutableResolver.Resolve( + userProfile: Path.Combine(root, "missing"), + pathEnvironment: root, + isWindows: true); + + result.Should().Be(toolPath); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/MauiCliJsonStreamParserTests.cs b/tests/MauiSherpa.Core.Tests/Services/MauiCliJsonStreamParserTests.cs new file mode 100644 index 00000000..3b974859 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/MauiCliJsonStreamParserTests.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class MauiCliJsonStreamParserTests +{ + [Fact] + public void Append_ParsesProfileResultAfterHumanOutput() + { + var parser = new MauiCliJsonStreamParser(); + var output = """ + $ maui profile startup + { + "project_path": "/repo/App.csproj", + "project_name": "App", + "framework": "net10.0-android", + "platform": "android", + "device_id": "emulator-5554", + "device_name": "Pixel 8", + "configuration": "Release", + "format": "speedscope", + "output_path": "/tmp/capture.speedscope.json", + "raw_trace_path": "/tmp/capture.nettrace", + "used_stopping_event": true + } + """; + + var messages = parser.Append(output); + + var result = messages.Should().ContainSingle() + .Which.Should().BeOfType().Subject.Result; + result.OutputPath.Should().EndWith("capture.speedscope.json"); + result.RawTracePath.Should().EndWith("capture.nettrace"); + result.UsedStoppingEvent.Should().BeTrue(); + } + + [Fact] + public void Append_ParsesJsonAcrossFragmentsAndFutureProperties() + { + var parser = new MauiCliJsonStreamParser(); + + parser.Append("{\"status\":\"pro").Should().BeEmpty(); + var messages = parser.Append("gress\",\"message\":\"Building\",\"percentage\":25,\"future\":true}"); + + messages.Should().ContainSingle() + .Which.Should().Be(new MauiCliStatusMessage("progress", "Building", 25)); + } + + [Fact] + public void Append_ParsesCanonicalErrorEnvelope() + { + var parser = new MauiCliJsonStreamParser(); + var output = """ + { + "code": "E2403", + "category": "platform", + "severity": "error", + "message": "Diagnostics tool not found", + "remediation": { + "type": "useraction", + "manual_steps": ["Install dotnet-trace", "Retry"] + } + } + """; + + var error = parser.Append(output).Should().ContainSingle() + .Which.Should().BeOfType().Subject; + + error.Code.Should().Be("E2403"); + error.Remediation!.ManualSteps.Should().HaveCount(2); + } + + [Fact] + public void Append_ParsesDeviceList() + { + var parser = new MauiCliJsonStreamParser(); + var output = """ + [ + { + "name": "Pixel 8", + "identifier": "emulator-5554", + "platforms": ["android"], + "version": "35", + "is_emulator": true, + "is_running": true + } + ] + """; + + var list = parser.Append(output).Should().ContainSingle() + .Which.Should().BeOfType().Subject; + + list.Devices.Should().ContainSingle(); + list.Devices[0].Platform.Should().Be("android"); + list.Devices[0].IsRunning.Should().BeTrue(); + } + + [Fact] + public void Append_IgnoresInvalidBraceDelimitedHumanOutput() + { + var parser = new MauiCliJsonStreamParser(); + + var messages = parser.Append("Building target {not-json}\n"); + + messages.Should().BeEmpty(); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/MauiCliToolServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/MauiCliToolServiceTests.cs new file mode 100644 index 00000000..9e9c9dc4 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/MauiCliToolServiceTests.cs @@ -0,0 +1,270 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using MauiSherpa.Workloads.NuGet; +using Moq; +using NuGet.Versioning; + +namespace MauiSherpa.Core.Tests.Services; + +public class MauiCliToolServiceTests +{ + [Fact] + public async Task GetStatusAsync_ReportsAvailableWhenProfileCommandsExist() + { + var process = new QueueProcessExecutionService( + Completed("""{"version":"1.2.3","runtime":"10.0","os":"macOS"}"""), + Completed("startup help"), + Completed("manual help")); + var service = CreateService(process, () => "/fake/maui"); + + var status = await service.GetStatusAsync(); + + status.State.Should().Be(MauiCliToolState.Available); + status.Version.Should().Be("1.2.3"); + process.Requests.Should().HaveCount(3); + } + + [Fact] + public async Task GetStatusAsync_ReportsMissingWithoutExecutable() + { + var process = new QueueProcessExecutionService(); + var service = CreateService(process, () => null); + + var status = await service.GetStatusAsync(); + + status.State.Should().Be(MauiCliToolState.Missing); + process.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task GetDevicesAsync_ReturnsOnlySupportedRunningTargets() + { + var devicesJson = """ + [ + {"name":"Pixel","identifier":"android-1","platforms":["android"],"is_emulator":true,"is_running":true}, + {"name":"Stopped","identifier":"android-2","platforms":["android"],"is_emulator":true,"is_running":false}, + {"name":"iPhone","identifier":"ios-1","platforms":["ios"],"is_emulator":true,"is_running":true}, + {"name":"Physical iPhone","identifier":"ios-2","platforms":["ios"],"is_emulator":false,"is_running":true}, + {"name":"Desktop","identifier":"mac-1","platforms":["maccatalyst"],"is_emulator":false,"is_running":true} + ] + """; + var process = new QueueProcessExecutionService( + Completed("""{"version":"1.2.3"}"""), + Completed("startup help"), + Completed("manual help"), + Completed(devicesJson)); + var service = CreateService(process, () => "/fake/maui"); + + var devices = await service.GetDevicesAsync(); + + devices.Select(x => x.Identifier).Should().Equal("android-1", "ios-1"); + } + + [Fact] + public async Task InstallAsync_FallsBackToPrereleaseWhenVersionIsUnknown() + { + var process = new QueueProcessExecutionService(Completed("installed")); + var service = CreateService(process, () => null); + + var result = await service.InstallAsync(); + + result.Success.Should().BeTrue(); + process.Requests.Should().ContainSingle() + .Which.Arguments.Should().Equal( + "tool", + "install", + "--global", + "Microsoft.Maui.Cli", + "--prerelease"); + } + + [Fact] + public async Task UpdateAsync_PinsTheResolvedVersionWhenKnown() + { + var process = new QueueProcessExecutionService(Completed("updated")); + var service = CreateService(process, () => "/fake/maui"); + + var result = await service.UpdateAsync("0.1.0-preview.12.26368.2"); + + result.Success.Should().BeTrue(); + process.Requests.Should().ContainSingle() + .Which.Arguments.Should().Equal( + "tool", + "update", + "--global", + "Microsoft.Maui.Cli", + "--version", + "0.1.0-preview.12.26368.2"); + } + + [Fact] + public async Task GetUpdateInfoAsync_ReportsUpdateWhenNewerPrereleaseExists() + { + var service = CreateService( + new QueueProcessExecutionService(), + () => "/fake/maui", + new StubNuGetClient("0.1.0-preview.12.26358.3", "0.1.0-preview.12.26368.2")); + + var info = await service.GetUpdateInfoAsync( + new MauiCliToolStatus( + MauiCliToolState.Available, + "/fake/maui", + "0.1.0-preview.12.26358.3+370e95b72f9b")); + + info.UpdateAvailable.Should().BeTrue(); + info.InstalledVersion.Should().Be("0.1.0-preview.12.26358.3"); + info.LatestVersion.Should().Be("0.1.0-preview.12.26368.2"); + } + + [Fact] + public async Task GetUpdateInfoAsync_ReportsUpToDateOnLatestVersion() + { + var service = CreateService( + new QueueProcessExecutionService(), + () => "/fake/maui", + new StubNuGetClient("0.1.0-preview.11.26317.2", "0.1.0-preview.12.26358.3")); + + var info = await service.GetUpdateInfoAsync( + new MauiCliToolStatus(MauiCliToolState.Available, "/fake/maui", "0.1.0-preview.12.26358.3")); + + info.UpdateAvailable.Should().BeFalse(); + info.LatestVersion.Should().Be("0.1.0-preview.12.26358.3"); + } + + [Fact] + public async Task GetUpdateInfoAsync_SkipsFeedLookupWhenToolIsMissing() + { + var nuget = new StubNuGetClient("1.0.0"); + var service = CreateService(new QueueProcessExecutionService(), () => null, nuget); + + var info = await service.GetUpdateInfoAsync(new MauiCliToolStatus(MauiCliToolState.Missing)); + + info.UpdateAvailable.Should().BeFalse(); + info.LatestVersion.Should().BeNull(); + nuget.QueryCount.Should().Be(0); + } + + [Fact] + public async Task GetUpdateInfoAsync_ReturnsMessageWhenFeedLookupFails() + { + var service = CreateService( + new QueueProcessExecutionService(), + () => "/fake/maui", + new StubNuGetClient(new InvalidOperationException("feed offline"))); + + var info = await service.GetUpdateInfoAsync( + new MauiCliToolStatus(MauiCliToolState.Available, "/fake/maui", "0.1.0-preview.12.26358.3")); + + info.UpdateAvailable.Should().BeFalse(); + info.InstalledVersion.Should().Be("0.1.0-preview.12.26358.3"); + info.Message.Should().Contain("feed offline"); + } + + private static MauiCliToolService CreateService( + IProcessExecutionService process, + Func resolver) + { + return new MauiCliToolService( + process, + Mock.Of(), + resolver); + } + + private static MauiCliToolService CreateService( + IProcessExecutionService process, + Func resolver, + INuGetClient nugetClient) + { + return new MauiCliToolService( + process, + Mock.Of(), + resolver, + () => nugetClient); + } + + private sealed class StubNuGetClient : INuGetClient + { + private readonly string[] _versions; + private readonly Exception? _failure; + + public StubNuGetClient(params string[] versions) => _versions = versions; + + public StubNuGetClient(Exception failure) + { + _versions = []; + _failure = failure; + } + + public int QueryCount { get; private set; } + + public Task> GetPackageVersionsAsync( + string packageId, + bool includePrerelease = false, + CancellationToken cancellationToken = default) + { + QueryCount++; + if (_failure is not null) + return Task.FromException>(_failure); + + IReadOnlyList parsed = _versions + .Select(NuGetVersion.Parse) + .Where(x => includePrerelease || !x.IsPrerelease) + .ToArray(); + return Task.FromResult(parsed); + } + + public Task DownloadPackageAsync( + string packageId, + NuGetVersion version, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task GetPackageFileContentAsync( + string packageId, + NuGetVersion version, + string filePath, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + private static ProcessResult Completed(string output) => + new(0, output, string.Empty, TimeSpan.Zero, ProcessState.Completed); + + private sealed class QueueProcessExecutionService(params ProcessResult[] results) + : IProcessExecutionService + { + private readonly Queue _results = new(results); + + public List Requests { get; } = []; + public ProcessState CurrentState { get; private set; } = ProcessState.Pending; + public int? ProcessId => null; + public event EventHandler? OutputReceived; + public event EventHandler? StateChanged; + + public Task ExecuteAsync( + ProcessRequest request, + CancellationToken cancellationToken = default) + { + Requests.Add(request); + var result = _results.Dequeue(); + CurrentState = result.FinalState; + return Task.FromResult(result); + } + + public Task SendInputAsync( + string data, + CancellationToken cancellationToken = default) => Task.FromResult(true); + + public void Cancel() + { + } + + public void Kill() + { + } + + public string GetFullOutput() => string.Empty; + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/MauiProfileArtifactRecoveryTests.cs b/tests/MauiSherpa.Core.Tests/Services/MauiProfileArtifactRecoveryTests.cs new file mode 100644 index 00000000..b610339f --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/MauiProfileArtifactRecoveryTests.cs @@ -0,0 +1,163 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class MauiProfileArtifactRecoveryTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + $"sherpa-recovery-{Guid.NewGuid():N}"); + + public MauiProfileArtifactRecoveryTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } + + [Fact] + public void TryRecover_UsesMibcPrimaryAndNettraceCompanion() + { + WriteArtifact("capture.nettrace"); + WriteArtifact("capture.mibc"); + WriteArtifact("capture.etlx"); + + var started = DateTimeOffset.UtcNow.AddMinutes(-2); + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.Mibc), + started, + DateTimeOffset.UtcNow, + "Building net10.0-android for emulator-5554"); + + result.Should().NotBeNull(); + result!.RecoveredFromDisk.Should().BeTrue(); + Path.GetFileName(result.OutputPath).Should().Be("capture.mibc"); + Path.GetFileName(result.RawTracePath!).Should().Be("capture.nettrace"); + result.Format.Should().Be("mibc"); + result.Framework.Should().Be("net10.0-android"); + result.DeviceName.Should().Be("Pixel 9"); + result.ProjectName.Should().Be("MauiApp"); + result.UsedStoppingEvent.Should().BeTrue(); + } + + [Fact] + public void TryRecover_PrefersSpeedscopeWhenRequested() + { + WriteArtifact("capture.nettrace"); + WriteArtifact("capture.speedscope.json"); + + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.Speedscope), + DateTimeOffset.UtcNow.AddMinutes(-2), + DateTimeOffset.UtcNow); + + Path.GetFileName(result!.OutputPath).Should().Be("capture.speedscope.json"); + Path.GetFileName(result.RawTracePath!).Should().Be("capture.nettrace"); + result.Format.Should().Be("speedscope"); + } + + [Fact] + public void TryRecover_FallsBackToRawTraceWhenConversionMissing() + { + WriteArtifact("capture.nettrace"); + + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.Speedscope), + DateTimeOffset.UtcNow.AddMinutes(-2), + DateTimeOffset.UtcNow); + + Path.GetFileName(result!.OutputPath).Should().Be("capture.nettrace"); + result.RawTracePath.Should().BeNull(); + result.Format.Should().Be("nettrace"); + } + + [Fact] + public void TryRecover_ReturnsNullWhenNoArtifactsExist() + { + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.Speedscope), + DateTimeOffset.UtcNow.AddMinutes(-2), + DateTimeOffset.UtcNow); + + result.Should().BeNull(); + } + + [Fact] + public void TryRecover_IgnoresEmptyArtifacts() + { + File.WriteAllBytes(Path.Combine(_root, "capture.nettrace"), []); + + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.NetTrace), + DateTimeOffset.UtcNow.AddMinutes(-2), + DateTimeOffset.UtcNow); + + result.Should().BeNull(); + } + + [Fact] + public void TryRecover_IgnoresArtifactsWrittenBeforeTheRun() + { + var path = WriteArtifact("capture.nettrace"); + File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddHours(-3)); + + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.NetTrace), + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow); + + result.Should().BeNull(); + } + + [Fact] + public void TryRecover_LeavesFrameworkEmptyWhenOutputHasNoMoniker() + { + WriteArtifact("capture.nettrace"); + + var result = MauiProfileArtifactRecovery.TryRecover( + CreateRequest(MauiProfileOutputFormat.NetTrace), + DateTimeOffset.UtcNow.AddMinutes(-2), + DateTimeOffset.UtcNow, + "no framework here"); + + result!.Framework.Should().BeEmpty(); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("Unhandled exception. System.IO.IOException: disk full", false)] + [InlineData( + "System.InvalidOperationException: JsonTypeInfo metadata for type " + + "'Microsoft.Maui.Cli.Commands.MauiProfileResult' was not provided by TypeInfoResolver " + + "of type 'Microsoft.Maui.Cli.Output.MauiCliJsonContext'.", + true)] + public void IsResultSerializationFailure_DetectsKnownCliDefect(string? output, bool expected) + { + MauiProfileArtifactRecovery.IsResultSerializationFailure(output).Should().Be(expected); + } + + private string WriteArtifact(string fileName) + { + var path = Path.Combine(_root, fileName); + File.WriteAllText(path, "artifact"); + return path; + } + + private MauiProfileRequest CreateRequest(MauiProfileOutputFormat format) => new() + { + ProjectPath = "/repo/MauiApp.csproj", + Platform = ProfilingTargetPlatform.Android, + DeviceId = "emulator-5554", + DeviceName = "Pixel 9", + IsEmulator = true, + Mode = MauiProfileMode.Startup, + Format = format, + OutputPath = Path.Combine(_root, "capture.nettrace") + }; +} diff --git a/tests/MauiSherpa.Core.Tests/Services/MauiProfileCommandBuilderTests.cs b/tests/MauiSherpa.Core.Tests/Services/MauiProfileCommandBuilderTests.cs new file mode 100644 index 00000000..2c3b986d --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/MauiProfileCommandBuilderTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class MauiProfileCommandBuilderTests +{ + [Fact] + public void BuildArguments_StartupUsesHelperEventByDefault() + { + var arguments = MauiProfileCommandBuilder.BuildArguments(CreateRequest()); + + arguments.Should().ContainInOrder( + "profile", + "startup", + "--stopping-event-provider-name", + MauiProfileCommandBuilder.StartupProviderName, + "--stopping-event-event-name", + MauiProfileCommandBuilder.StartupEventName, + "--json", + "--ci"); + } + + [Fact] + public void BuildArguments_DurationReplacesHelperEvent() + { + var request = CreateRequest() with { Duration = TimeSpan.FromSeconds(15) }; + + var arguments = MauiProfileCommandBuilder.BuildArguments(request); + + arguments.Should().ContainInOrder("--duration", "00:00:15"); + arguments.Should().NotContain("--stopping-event-provider-name"); + } + + [Fact] + public void BuildArguments_InteractionIncludesAdvancedOptions() + { + var request = CreateRequest() with + { + Mode = MauiProfileMode.Interaction, + Format = MauiProfileOutputFormat.Mibc, + NoBuild = true, + TraceProfile = "cpu-sampling,gc-verbose" + }; + + var arguments = MauiProfileCommandBuilder.BuildArguments(request); + + arguments.Should().ContainInOrder("profile", "manual"); + arguments.Should().ContainInOrder("--format", "mibc"); + arguments.Should().ContainInOrder("--trace-profile", "cpu-sampling,gc-verbose"); + arguments.Count(x => x == "--trace-profile").Should().Be(1); + arguments.Should().Contain("--no-build"); + arguments.Should().NotContain("--duration"); + } + + [Fact] + public void FormatForDisplay_QuotesPathsWithoutChangingArguments() + { + var request = CreateRequest() with + { + ProjectPath = "/repo/My App/My App.csproj", + OutputPath = "/tmp/Profile Output/capture.nettrace" + }; + + var command = MauiProfileCommandBuilder.FormatForDisplay("/Users/me/.dotnet/tools/maui", request); + + command.Should().Contain("\"/repo/My App/My App.csproj\""); + command.Should().Contain("\"/tmp/Profile Output/capture.nettrace\""); + } + + private static MauiProfileRequest CreateRequest() => new() + { + ProjectPath = "/repo/App.csproj", + Platform = ProfilingTargetPlatform.Android, + DeviceId = "emulator-5554", + Mode = MauiProfileMode.Startup, + OutputPath = "/tmp/capture.nettrace" + }; +} diff --git a/tests/MauiSherpa.Core.Tests/Services/MauiProfilingCliServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/MauiProfilingCliServiceTests.cs new file mode 100644 index 00000000..17ec8632 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/MauiProfilingCliServiceTests.cs @@ -0,0 +1,344 @@ +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class MauiProfilingCliServiceTests +{ + [Fact] + public async Task InteractionProfile_UsesNewlinesForBeginAndStop() + { + var process = new ControllableProcessExecutionService(); + var toolService = CreateToolService(); + using var service = new MauiProfilingCliService( + process, + toolService.Object, + Mock.Of()); + + var runTask = service.RunAsync(CreateRequest(MauiProfileMode.Interaction)); + + service.State.Should().Be(MauiProfileRunState.AwaitingRecording); + + await service.BeginRecordingAsync(); + service.State.Should().Be(MauiProfileRunState.Recording); + + await service.StopRecordingAsync(); + service.State.Should().Be(MauiProfileRunState.Finalizing); + process.Inputs.Should().Equal(Environment.NewLine, Environment.NewLine); + process.Request!.AcceptsStandardInput.Should().BeTrue(); + + process.Emit(ProfileResultJson); + process.Complete(new ProcessResult( + 0, + ProfileResultJson, + string.Empty, + TimeSpan.FromSeconds(1), + ProcessState.Completed)); + + var result = await runTask; + + result.Success.Should().BeTrue(); + result.Profile!.DeviceId.Should().Be("emulator-5554"); + service.State.Should().Be(MauiProfileRunState.Completed); + } + + [Fact] + public async Task RunAsync_MapsStructuredFailure() + { + var process = new ControllableProcessExecutionService(); + using var service = new MauiProfilingCliService( + process, + CreateToolService().Object, + Mock.Of()); + + var runTask = service.RunAsync(CreateRequest(MauiProfileMode.Startup)); + process.Emit(""" + {"code":"E2111","category":"platform","severity":"error","message":"No running Android device."} + """); + process.Complete(new ProcessResult( + 1, + string.Empty, + string.Empty, + TimeSpan.Zero, + ProcessState.Failed)); + + var result = await runTask; + + result.Success.Should().BeFalse(); + result.Error!.Code.Should().Be("E2111"); + service.State.Should().Be(MauiProfileRunState.Failed); + } + + [Fact] + public async Task Cancel_UsesAbortPath() + { + var process = new ControllableProcessExecutionService(); + using var service = new MauiProfilingCliService( + process, + CreateToolService().Object, + Mock.Of()); + + var runTask = service.RunAsync(CreateRequest(MauiProfileMode.Startup)); + service.Cancel(); + + var result = await runTask; + + process.CancelCalled.Should().BeTrue(); + result.WasCancelled.Should().BeTrue(); + service.State.Should().Be(MauiProfileRunState.Cancelled); + } + + [Fact] + public async Task RunAsync_RecoversProfileWhenCliFailsAfterWritingArtifacts() + { + var directory = Path.Combine(Path.GetTempPath(), $"sherpa-cli-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var outputPath = Path.Combine(directory, "capture.nettrace"); + File.WriteAllText(outputPath, "trace"); + File.WriteAllText(Path.Combine(directory, "capture.mibc"), "mibc"); + File.WriteAllText(Path.Combine(directory, "capture.etlx"), "etlx"); + + var process = new ControllableProcessExecutionService(); + using var service = new MauiProfilingCliService( + process, + CreateToolService().Object, + Mock.Of()); + + var request = CreateRequest(MauiProfileMode.Startup) with + { + Format = MauiProfileOutputFormat.Mibc, + OutputPath = outputPath + }; + + var runTask = service.RunAsync(request); + process.Emit(CliSerializationCrashEnvelope); + process.Complete(new ProcessResult( + 1, + CliSerializationCrashEnvelope, + string.Empty, + TimeSpan.FromSeconds(30), + ProcessState.Failed)); + + var result = await runTask; + + result.Success.Should().BeTrue(); + result.Error.Should().BeNull(); + result.Profile!.RecoveredFromDisk.Should().BeTrue(); + Path.GetFileName(result.Profile.OutputPath).Should().Be("capture.mibc"); + Path.GetFileName(result.Profile.RawTracePath!).Should().Be("capture.nettrace"); + service.State.Should().Be(MauiProfileRunState.Completed); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task RunAsync_ReportsSerializationDefectWhenNothingWasCaptured() + { + var process = new ControllableProcessExecutionService(); + using var service = new MauiProfilingCliService( + process, + CreateToolService().Object, + Mock.Of()); + + var runTask = service.RunAsync(CreateRequest(MauiProfileMode.Startup)); + process.Emit(CliSerializationCrashEnvelope); + process.Complete(new ProcessResult( + 1, + CliSerializationCrashEnvelope, + string.Empty, + TimeSpan.Zero, + ProcessState.Failed)); + + var result = await runTask; + + result.Success.Should().BeFalse(); + result.Error!.Code.Should().Be("SHERPA_PROFILE_CLI_RESULT_SERIALIZATION"); + result.Error.Remediation!.Command.Should().Be("dotnet tool update -g Microsoft.Maui.Cli"); + service.State.Should().Be(MauiProfileRunState.Failed); + } + + [Fact] + public async Task RunAsync_KeepsUnrelatedCliErrorsEvenWhenArtifactsExist() + { + var directory = Path.Combine(Path.GetTempPath(), $"sherpa-cli-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var outputPath = Path.Combine(directory, "capture.nettrace"); + File.WriteAllText(outputPath, "partial trace"); + + var process = new ControllableProcessExecutionService(); + using var service = new MauiProfilingCliService( + process, + CreateToolService().Object, + Mock.Of()); + + var runTask = service.RunAsync( + CreateRequest(MauiProfileMode.Startup) with { OutputPath = outputPath }); + process.Emit(""" + {"code":"E2111","category":"platform","severity":"error","message":"No running Android device."} + """); + process.Complete(new ProcessResult( + 1, + string.Empty, + string.Empty, + TimeSpan.Zero, + ProcessState.Failed)); + + var result = await runTask; + + result.Success.Should().BeFalse(); + result.Profile.Should().BeNull(); + result.Error!.Code.Should().Be("E2111"); + service.State.Should().Be(MauiProfileRunState.Failed); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task RunAsync_DoesNotRecoverAfterCancellation() + { + var directory = Path.Combine(Path.GetTempPath(), $"sherpa-cli-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var outputPath = Path.Combine(directory, "capture.nettrace"); + File.WriteAllText(outputPath, "partial trace"); + + var process = new ControllableProcessExecutionService(); + using var service = new MauiProfilingCliService( + process, + CreateToolService().Object, + Mock.Of()); + + var runTask = service.RunAsync( + CreateRequest(MauiProfileMode.Startup) with { OutputPath = outputPath }); + service.Cancel(); + + var result = await runTask; + + result.WasCancelled.Should().BeTrue(); + result.Profile.Should().BeNull(); + service.State.Should().Be(MauiProfileRunState.Cancelled); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static Mock CreateToolService() + { + var mock = new Mock(); + mock.Setup(x => x.GetStatusAsync(It.IsAny())) + .ReturnsAsync(new MauiCliToolStatus( + MauiCliToolState.Available, + "/fake/maui", + "1.2.3")); + return mock; + } + + private static MauiProfileRequest CreateRequest(MauiProfileMode mode) => new() + { + ProjectPath = "/repo/App.csproj", + Platform = ProfilingTargetPlatform.Android, + DeviceId = "emulator-5554", + Mode = mode, + OutputPath = "/tmp/capture.nettrace" + }; + + // Verbatim stdout from Microsoft.Maui.Cli 0.1.0-preview.12: the CLI writes the trace, + // then reports its own result-serialization defect as a normal error envelope. + private const string CliSerializationCrashEnvelope = """ + { + "code": "E1001", + "category": "tool", + "severity": "error", + "message": "JsonTypeInfo metadata for type 'Microsoft.Maui.Cli.Commands.MauiProfileResult' was not provided by TypeInfoResolver of type 'Microsoft.Maui.Cli.Output.MauiCliJsonContext'. If using source generation, ensure that all root types passed to the serializer have been annotated with 'JsonSerializableAttribute', along with any types that might be serialized polymorphically." + } + """; + + private const string ProfileResultJson = """ + { + "project_path":"/repo/App.csproj", + "project_name":"App", + "framework":"net10.0-android", + "platform":"android", + "device_id":"emulator-5554", + "device_name":"Pixel", + "configuration":"Release", + "format":"speedscope", + "output_path":"/tmp/capture.speedscope.json", + "raw_trace_path":"/tmp/capture.nettrace", + "used_stopping_event":false + } + """; + + private sealed class ControllableProcessExecutionService : IProcessExecutionService + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public List Inputs { get; } = []; + public bool CancelCalled { get; private set; } + public ProcessRequest? Request { get; private set; } + public ProcessState CurrentState { get; private set; } = ProcessState.Pending; + public int? ProcessId => 123; + public event EventHandler? OutputReceived; + public event EventHandler? StateChanged; + + public Task ExecuteAsync( + ProcessRequest request, + CancellationToken cancellationToken = default) + { + Request = request; + CurrentState = ProcessState.Running; + return _completion.Task; + } + + public Task SendInputAsync( + string data, + CancellationToken cancellationToken = default) + { + Inputs.Add(data); + return Task.FromResult(true); + } + + public void Emit(string output) => + OutputReceived?.Invoke(this, new ProcessOutputEventArgs(output)); + + public void Complete(ProcessResult result) + { + CurrentState = result.FinalState; + _completion.TrySetResult(result); + } + + public void Cancel() + { + CancelCalled = true; + Complete(new ProcessResult( + 130, + string.Empty, + string.Empty, + TimeSpan.Zero, + ProcessState.Cancelled)); + } + + public void Kill() + { + } + + public string GetFullOutput() => string.Empty; + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactClassifierTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactClassifierTests.cs new file mode 100644 index 00000000..11d37785 --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingArtifactClassifierTests.cs @@ -0,0 +1,28 @@ +using FluentAssertions; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingArtifactClassifierTests +{ + [Theory] + [InlineData("capture.nettrace", ProfilingArtifactKind.Trace)] + [InlineData("capture.speedscope.json", ProfilingArtifactKind.Trace)] + [InlineData("capture.mibc", ProfilingArtifactKind.Mibc)] + [InlineData("memory.gcdump", ProfilingArtifactKind.GcDump)] + [InlineData("capture.log", ProfilingArtifactKind.Log)] + [InlineData("capture.txt", ProfilingArtifactKind.Log)] + [InlineData("capture.bin", ProfilingArtifactKind.Other)] + public void Classify_ReturnsExpectedKind(string path, ProfilingArtifactKind expected) + { + ProfilingArtifactClassifier.Classify(path).Should().Be(expected); + } + + [Fact] + public void GetBaseName_RemovesFullSpeedscopeSuffix() + { + ProfilingArtifactClassifier.GetBaseName("my-profile.speedscope.json") + .Should().Be("my-profile"); + } +} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs deleted file mode 100644 index 00387c71..00000000 --- a/tests/MauiSherpa.Core.Tests/Services/ProfilingCaptureOrchestrationServiceTests.cs +++ /dev/null @@ -1,344 +0,0 @@ -using FluentAssertions; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Services; -using Moq; - -namespace MauiSherpa.Core.Tests.Services; - -public class ProfilingCaptureOrchestrationServiceTests -{ - private readonly ProfilingCatalogService _catalogService = new([]); - private readonly Mock _prerequisitesService = new(); - private readonly Mock _deviceMonitorService = new(); - private readonly Mock _platformService = new(); - private readonly Mock _androidSdkSettingsService = new(); - private readonly Mock _loggingService = new(); - - public ProfilingCaptureOrchestrationServiceTests() - { - _platformService.SetupGet(x => x.PlatformName).Returns("macOS"); - _platformService.SetupGet(x => x.IsMacOS).Returns(true); - _platformService.SetupGet(x => x.IsMacCatalyst).Returns(false); - _platformService.SetupGet(x => x.IsWindows).Returns(false); - _platformService.SetupGet(x => x.IsLinux).Returns(false); - _deviceMonitorService.SetupGet(x => x.Current).Returns(ConnectedDevicesSnapshot.Empty); - _androidSdkSettingsService.Setup(x => x.GetEffectiveSdkPathAsync()) - .ReturnsAsync("/Users/test/Library/Android/sdk"); - _prerequisitesService.Setup(x => x.GetPrerequisitesAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync((ProfilingTargetPlatform platform, IReadOnlyList? captureKinds, string? _, CancellationToken _) => - CreateReadyPrerequisites(platform, captureKinds ?? [])); - } - - [Fact] - public async Task PlanCaptureAsync_AndroidEmulatorLaunch_UsesDsRouterServerServerAndDiagnosticBuildProperties() - { - var snapshot = ConnectedDevicesSnapshot.Empty with - { - AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] - }; - _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); - - var service = CreateService(); - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.Emulator, - "emulator-5554", - "Pixel 8"), - ProfilingScenarioKind.Launch, - appId: "com.example.app"); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( - ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); - - plan.Validation.IsValid.Should().BeTrue(); - plan.CanExecute.Should().BeTrue(); - plan.Diagnostics.Should().NotBeNull(); - plan.Diagnostics!.DsRouterMode.Should().Be(ProfilingDsRouterMode.ServerServer); - plan.Diagnostics.Address.Should().Be("10.0.2.2"); - plan.IsTargetCurrentlyAvailable.Should().BeTrue(); - - // SuspendAtStartup defaults to false, so trace goes post-launch (after build). - // Android also gets adb setup-diagnostic-port step before build-and-run. - // Trace is on-demand (not in pipeline commands), but artifact is still expected. - plan.Commands.Select(command => command.Id).Should().ContainInOrder( - "start-dsrouter", - "setup-diagnostic-port", - "build-and-run"); - plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); - plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); - - // Both trace and GC dump are on-demand, not in the pipeline commands, but still expected artifacts - plan.ExpectedArtifacts.Should().Contain(a => a.DisplayName == "GC dump"); - plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); - - // The adb setprop step configures the Mono diagnostic port - var setupStep = plan.Commands.Single(command => command.Id == "setup-diagnostic-port"); - setupStep.CommandLine.Should().Contain("debug.mono.profile"); - setupStep.CommandLine.Should().Contain("10.0.2.2:9000"); - - var buildStep = plan.Commands.Single(command => command.Id == "build-and-run"); - buildStep.CommandLine.Should().Contain("-p:AndroidEnableProfiler=true"); - buildStep.CommandLine.Should().NotContain("-p:DiagnosticAddress"); - buildStep.CommandLine.Should().Contain("-f net10.0-android"); - buildStep.CanRunParallel.Should().BeTrue(); - buildStep.StopTrigger.Should().Be(ProfilingStopTrigger.OnPipelineStop); - buildStep.Environment.Should().ContainKey("ANDROID_SERIAL"); - } - - [Fact] - public async Task PlanCaptureAsync_IosPhysicalDeviceLaunch_UsesDsRouterServerClientAndListenMode() - { - var snapshot = ConnectedDevicesSnapshot.Empty with - { - ApplePhysicalDevices = - [ - new AppleDeviceInfo("00008110-000E64C20A91801E", "Test iPhone", "iPhone 15", "iOS", "arm64", "17.5", false, true, "usb", null) - ] - }; - _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); - - var service = CreateService(); - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.iOS, - ProfilingTargetKind.PhysicalDevice, - "00008110-000E64C20A91801E", - "Test iPhone"), - ProfilingScenarioKind.Launch, - appId: "com.example.iosapp"); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( - ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); - - plan.Validation.IsValid.Should().BeTrue(); - plan.CanExecute.Should().BeTrue(); - plan.Diagnostics.Should().NotBeNull(); - plan.Diagnostics!.DsRouterMode.Should().Be(ProfilingDsRouterMode.ServerClient); - plan.Diagnostics.ListenMode.Should().Be(ProfilingDiagnosticListenMode.Listen); - - // Trace is on-demand (not in pipeline commands), but artifact is still expected. - plan.Commands.Select(command => command.Id).Should().ContainInOrder( - "start-dsrouter", - "build-and-run"); - plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); - plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); - - // Both trace and GC dump are on-demand, not in the pipeline commands, but still expected artifacts - plan.ExpectedArtifacts.Should().Contain(a => a.DisplayName == "GC dump"); - plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); - - var buildStep = plan.Commands.Single(command => command.Id == "build-and-run"); - buildStep.CommandLine.Should().Contain("-f net10.0-ios"); - buildStep.CommandLine.Should().NotContain("-p:DiagnosticListenMode"); - buildStep.CommandLine.Should().NotContain("-p:DiagnosticAddress"); - } - - [Fact] - public async Task PlanCaptureAsync_AndroidEmulatorTraceOnly_UsesDsRouter() - { - var snapshot = ConnectedDevicesSnapshot.Empty with - { - AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] - }; - _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); - - var service = CreateService(); - // Only CPU trace, no memory — still uses standalone dsrouter for on-demand trace - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.Emulator, - "emulator-5554", - "Pixel 8"), - ProfilingScenarioKind.Launch, - captureKinds: [ProfilingCaptureKind.Cpu], - appId: "com.example.app"); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( - ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); - - plan.Validation.IsValid.Should().BeTrue(); - // Even trace-only uses standalone dsrouter since trace is now on-demand - plan.Commands.Should().Contain(command => command.Id == "start-dsrouter"); - // Trace is on-demand, not in pipeline commands - plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); - // But trace artifact is expected - plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); - } - - [Fact] - public async Task PlanCaptureAsync_MacCatalystLaunch_AddsRuntimeBindingForProcessAttach() - { - var service = CreateService(); - var session = new ProfilingSessionDefinition( - "session-1", - "Mac Catalyst Trace", - new ProfilingTarget( - ProfilingTargetPlatform.MacCatalyst, - ProfilingTargetKind.Desktop, - "local-desktop", - "MAUI Sherpa"), - ProfilingScenarioKind.Interaction, - [ProfilingCaptureKind.Cpu], - AppId: "codes.redth.mauisherpa", - Duration: TimeSpan.FromMinutes(5), - CreatedAt: DateTimeOffset.UtcNow); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( - ProjectPath: "/Users/test/src/MauiSherpa/MauiSherpa.csproj")); - - plan.Validation.IsValid.Should().BeTrue(); - plan.Diagnostics.Should().BeNull(); - plan.RequiresRuntimeInputs.Should().BeTrue(); - plan.CanExecute.Should().BeFalse(); - plan.RuntimeBindings.Should().ContainSingle(binding => binding.Token == "{{PROCESS_ID}}"); - plan.Commands.Select(command => command.Id).Should().ContainInOrder( - "build-and-run", - "discover-process-id"); - plan.Commands.Should().NotContain(command => command.Id == "capture-trace"); - plan.ExpectedArtifacts.Should().Contain(a => a.Kind == ProfilingArtifactKind.Trace); - } - - [Fact] - public async Task PlanCaptureAsync_DefaultOutputDirectory_UsesProjectNameAndDate() - { - var snapshot = ConnectedDevicesSnapshot.Empty with - { - AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] - }; - _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); - - var service = CreateService(); - var createdAt = new DateTimeOffset(2026, 3, 9, 14, 0, 0, TimeSpan.Zero); - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.Emulator, - "emulator-5554", - "Pixel 8"), - ProfilingScenarioKind.Launch, - appId: "com.example.app"); - session = session with { CreatedAt = createdAt }; - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( - ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); - - plan.Validation.IsValid.Should().BeTrue(); - var dateStr = createdAt.LocalDateTime.ToString("yyyy-MM-dd"); - var expectedDir = Path.GetFullPath( - Path.Combine("/Users/test/src/HelloMaui", "artifacts", "profiling", "HelloMaui", $"{dateStr}-1")); - plan.Options.OutputDirectory.Should().Be(expectedDir); - } - - [Fact] - public async Task PlanCaptureAsync_ArtifactFileNames_UseSimpleNames() - { - var snapshot = ConnectedDevicesSnapshot.Empty with - { - AndroidEmulators = [new DeviceInfo("emulator-5554", "device", "Pixel 8", true)] - }; - _deviceMonitorService.SetupGet(x => x.Current).Returns(snapshot); - - var service = CreateService(); - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.Emulator, - "emulator-5554", - "Pixel 8"), - ProfilingScenarioKind.Launch, - appId: "com.example.app"); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions( - ProjectPath: "/Users/test/src/HelloMaui/HelloMaui.csproj")); - - plan.Validation.IsValid.Should().BeTrue(); - plan.ExpectedArtifacts.Should().Contain(a => a.FileName == "trace.nettrace"); - plan.ExpectedArtifacts.Should().Contain(a => a.FileName == "memory.gcdump"); - } - - [Fact] - public async Task PlanCaptureAsync_NoProjectPath_FallsBackToSessionInOutputDir() - { - var service = CreateService(); - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.MacCatalyst, - ProfilingTargetKind.Desktop, - "local-desktop", - "MAUI Sherpa"), - ProfilingScenarioKind.Interaction, - appId: "codes.redth.mauisherpa"); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions()); - - // Missing project path in launch mode causes validation error, but for attach scenarios - // the output dir still uses "session" fallback when no project path is given - plan.Options.OutputDirectory.Should().Contain(Path.Combine("artifacts", "profiling", "session")); - } - - [Fact] - public async Task PlanCaptureAsync_LaunchWithoutProjectPath_ReturnsValidationError() - { - var service = CreateService(); - var session = _catalogService.CreateSessionDefinition( - new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.PhysicalDevice, - "device-01", - "Pixel 9"), - ProfilingScenarioKind.Launch); - - var plan = await service.PlanCaptureAsync(session, new ProfilingCapturePlanOptions()); - - plan.Validation.IsValid.Should().BeFalse(); - plan.Validation.Errors.Should().Contain(error => - error.Contains("project path", StringComparison.OrdinalIgnoreCase)); - } - - private ProfilingCaptureOrchestrationService CreateService() => - new( - _catalogService, - _prerequisitesService.Object, - _deviceMonitorService.Object, - _platformService.Object, - _androidSdkSettingsService.Object, - _loggingService.Object); - - private static ProfilingPrerequisiteReport CreateReadyPrerequisites( - ProfilingTargetPlatform platform, - IReadOnlyList captureKinds) - { - return new ProfilingPrerequisiteReport( - new ProfilingPrerequisiteContext( - platform, - captureKinds, - "/Users/test/code/MAUI.Sherpa", - "/usr/local/share/dotnet/dotnet", - new DoctorContext( - "/Users/test/code/MAUI.Sherpa", - "/usr/local/share/dotnet", - "/Users/test/code/MAUI.Sherpa/global.json", - "10.0.100", - null, - "10.0.100", - ActiveSdkVersion: "10.0.103", - ResolvedSdkVersion: "10.0.103")), - [new ProfilingPrerequisiteStatus( - "Host Platform", - ProfilingPrerequisiteKind.HostPlatform, - DependencyStatusType.Ok, - IsRequired: true, - RequiredVersion: null, - RecommendedVersion: null, - InstalledVersion: "macOS", - Message: "Host ready")], - DateTimeOffset.UtcNow); - } -} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs index 37a15cc6..c621638d 100644 --- a/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingCatalogServiceTests.cs @@ -1,8 +1,6 @@ using FluentAssertions; -using MauiSherpa.Core.Interfaces; using MauiSherpa.Core.Models.Profiling; using MauiSherpa.Core.Services; -using Moq; namespace MauiSherpa.Core.Tests.Services; @@ -11,49 +9,40 @@ public class ProfilingCatalogServiceTests [Fact] public async Task GetCatalogAsync_ReturnsBuiltInPlatformsAndScenarios() { - var service = new ProfilingCatalogService([]); + var service = new ProfilingCatalogService(); var result = await service.GetCatalogAsync(); - result.Platforms.Should().HaveCount(5); - result.Scenarios.Should().Contain(x => x.Kind == ProfilingScenarioKind.Launch); + result.Platforms.Should().HaveCount(2); + result.Scenarios.Should().HaveCount(2); + result.Scenarios.Should().Contain(x => + x.DisplayName == "Startup" && + x.DefaultCaptureKinds.SequenceEqual(new[] { ProfilingCaptureKind.Startup })); + result.Scenarios.Should().Contain(x => + x.DisplayName == "Interaction" && + x.DefaultCaptureKinds.SequenceEqual(new[] { ProfilingCaptureKind.Interaction })); result.Platforms.Should().Contain(x => x.Platform == ProfilingTargetPlatform.Android && x.SupportedTargetKinds.Contains(ProfilingTargetKind.Emulator)); + result.Platforms.Should().Contain(x => + x.Platform == ProfilingTargetPlatform.iOS && + x.SupportedTargetKinds.SequenceEqual(new[] { ProfilingTargetKind.Simulator })); } [Fact] - public async Task GetCapabilitiesAsync_UsesRegisteredProviderOverride() + public async Task GetCapabilitiesAsync_RejectsUnsupportedDesktopPlatforms() { - var customCapabilities = new ProfilingPlatformCapabilities( - ProfilingTargetPlatform.Android, - "Android (custom)", - [ProfilingTargetKind.PhysicalDevice], - [ProfilingCaptureKind.Cpu], - [ProfilingArtifactKind.Trace], - [ProfilingScenarioKind.Launch], - SupportsLaunchProfiling: true, - SupportsAttachToProcess: false, - SupportsLiveMetrics: false, - SupportsSymbolication: false, - Notes: "Custom override"); - - var provider = new Mock(); - provider.SetupGet(x => x.Platform).Returns(ProfilingTargetPlatform.Android); - provider.Setup(x => x.GetCapabilitiesAsync(It.IsAny())) - .ReturnsAsync(customCapabilities); - - var service = new ProfilingCatalogService([provider.Object]); - - var result = await service.GetCapabilitiesAsync(ProfilingTargetPlatform.Android); + var service = new ProfilingCatalogService(); + var act = () => service.GetCapabilitiesAsync(ProfilingTargetPlatform.MacCatalyst); - result.Should().Be(customCapabilities); + await act.Should().ThrowAsync() + .WithMessage("*Android devices/emulators and iOS simulators*"); } [Fact] public void CreateSessionDefinition_UsesScenarioDefaultsWhenCaptureKindsNotProvided() { - var service = new ProfilingCatalogService([]); + var service = new ProfilingCatalogService(); var target = new ProfilingTarget( ProfilingTargetPlatform.Android, ProfilingTargetKind.Emulator, @@ -62,9 +51,8 @@ public void CreateSessionDefinition_UsesScenarioDefaultsWhenCaptureKindsNotProvi var result = service.CreateSessionDefinition(target, ProfilingScenarioKind.Launch); - result.Name.Should().Be("Pixel 8 - Launch & startup"); - result.CaptureKinds.Should().BeEquivalentTo( - [ProfilingCaptureKind.Startup, ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory]); + result.Name.Should().Be("Pixel 8 - Startup"); + result.CaptureKinds.Should().BeEquivalentTo([ProfilingCaptureKind.Startup]); result.Duration.Should().Be(TimeSpan.FromMinutes(2)); result.Tags.Should().NotBeNull().And.BeEmpty(); } @@ -72,14 +60,14 @@ public void CreateSessionDefinition_UsesScenarioDefaultsWhenCaptureKindsNotProvi [Fact] public async Task ValidateSessionDefinition_ReturnsErrorsForUnsupportedValues() { - var service = new ProfilingCatalogService([]); - var capabilities = await service.GetCapabilitiesAsync(ProfilingTargetPlatform.Windows); + var service = new ProfilingCatalogService(); + var capabilities = await service.GetCapabilitiesAsync(ProfilingTargetPlatform.Android); var definition = new ProfilingSessionDefinition( "session-1", "", new ProfilingTarget( - ProfilingTargetPlatform.Android, - ProfilingTargetKind.Emulator, + ProfilingTargetPlatform.iOS, + ProfilingTargetKind.Simulator, "", "Android emulator"), ProfilingScenarioKind.Launch, diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs deleted file mode 100644 index c5f475c6..00000000 --- a/tests/MauiSherpa.Core.Tests/Services/ProfilingPrerequisitesServiceTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using FluentAssertions; -using MauiSherpa.Core.Interfaces; -using MauiSherpa.Core.Models.Profiling; -using MauiSherpa.Core.Services; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; - -namespace MauiSherpa.Core.Tests.Services; - -public class ProfilingPrerequisitesServiceTests -{ - private readonly Mock _doctorService = new(); - private readonly Mock _platformService = new(); - private readonly Mock _loggingService = new(); - - public ProfilingPrerequisitesServiceTests() - { - _platformService.SetupGet(x => x.PlatformName).Returns("macOS"); - _platformService.SetupGet(x => x.IsMacOS).Returns(true); - _platformService.SetupGet(x => x.IsMacCatalyst).Returns(false); - _platformService.SetupGet(x => x.IsWindows).Returns(false); - _platformService.SetupGet(x => x.IsLinux).Returns(false); - } - - [Fact] - public async Task GetPrerequisitesAsync_ForAndroidMemoryCapture_ReturnsReadyWhenRequiredToolsExist() - { - var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); - SetupDoctor(context, CreateDoctorReport( - context, - new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false), - new DependencyStatus("Android SDK", DependencyCategory.AndroidSdk, null, null, "/Users/test/Library/Android/sdk", DependencyStatusType.Ok, "Found SDK", false), - new DependencyStatus("Platform Tools", DependencyCategory.AndroidSdk, null, null, "Installed", DependencyStatusType.Ok, "adb available", false))); - - var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ - Package Id Version Commands - ---------------------------------------------------------- - dotnet-trace 10.0.41001 dotnet-trace - dotnet-gcdump 10.0.41001 dotnet-gcdump - dotnet-dsrouter 10.0.41001 dotnet-dsrouter - """))); - - var report = await service.GetPrerequisitesAsync( - ProfilingTargetPlatform.Android, - [ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory]); - - report.IsReady.Should().BeTrue(); - report.Checks.Should().ContainSingle(x => x.Name == "dotnet-trace" && x.Status == DependencyStatusType.Ok); - report.Checks.Should().ContainSingle(x => x.Name == "dotnet-gcdump" && x.IsRequired && x.Status == DependencyStatusType.Ok); - report.Checks.Should().ContainSingle(x => x.Name == "dotnet-dsrouter" && x.IsRequired && x.Status == DependencyStatusType.Ok); - report.Checks.Should().ContainSingle(x => x.Name == "Platform Tools" && x.Status == DependencyStatusType.Ok); - } - - [Fact] - public async Task GetPrerequisitesAsync_UpgradesRequiredAndroidPlatformToolsWarningToError() - { - var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); - SetupDoctor(context, CreateDoctorReport( - context, - new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false), - new DependencyStatus("Android SDK", DependencyCategory.AndroidSdk, null, null, "/Users/test/Library/Android/sdk", DependencyStatusType.Ok, "Found SDK", false), - new DependencyStatus("Platform Tools", DependencyCategory.AndroidSdk, null, null, null, DependencyStatusType.Warning, "Platform tools not installed", true, "install-android-package:platform-tools"))); - - var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ - Package Id Version Commands - ---------------------------------------------------------- - dotnet-trace 10.0.41001 dotnet-trace - dotnet-dsrouter 10.0.41001 dotnet-dsrouter - """))); - - var report = await service.GetPrerequisitesAsync( - ProfilingTargetPlatform.Android, - [ProfilingCaptureKind.Cpu]); - - report.IsReady.Should().BeFalse(); - report.Checks.Should().ContainSingle(x => - x.Name == "Platform Tools" && - x.Status == DependencyStatusType.Error && - x.Message!.Contains("required for profiling readiness", StringComparison.OrdinalIgnoreCase)); - } - - [Fact] - public async Task GetPrerequisitesAsync_RequiresGcDumpForMemoryCapture() - { - var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); - SetupDoctor(context, CreateDoctorReport( - context, - new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false))); - - var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ - Package Id Version Commands - -------------------------------------------------------- - dotnet-trace 10.0.41001 dotnet-trace - """))); - - var report = await service.GetPrerequisitesAsync( - ProfilingTargetPlatform.MacOS, - [ProfilingCaptureKind.Memory]); - - report.IsReady.Should().BeFalse(); - report.Checks.Should().ContainSingle(x => - x.Name == "dotnet-gcdump" && - x.Status == DependencyStatusType.Error && - x.IsRequired); - } - - [Fact] - public async Task GetPrerequisitesAsync_DoesNotWarnWhenToolMajorDoesNotMatchActiveSdk() - { - var context = CreateDoctorContext(activeSdkVersion: "10.0.103"); - SetupDoctor(context, CreateDoctorReport( - context, - new DependencyStatus(".NET SDK", DependencyCategory.DotNetSdk, null, "10.0.103", "10.0.103", DependencyStatusType.Ok, "SDK ready", false))); - - var service = CreateService((request, _) => Task.FromResult(CreateToolListResult(request, """ - Package Id Version Commands - -------------------------------------------------------- - dotnet-trace 9.0.553801 dotnet-trace - """))); - - var report = await service.GetPrerequisitesAsync( - ProfilingTargetPlatform.MacOS, - [ProfilingCaptureKind.Cpu]); - - report.IsReady.Should().BeTrue(); - report.Checks.Should().ContainSingle(x => - x.Name == "dotnet-trace" && - x.Status == DependencyStatusType.Ok && - x.RecommendedVersion == null && - x.RequiredVersion == null && - x.SuggestedCommand == null); - } - - private ProfilingPrerequisitesService CreateService( - Func> processExecutor) - { - return new ProfilingPrerequisitesService( - _doctorService.Object, - _platformService.Object, - _loggingService.Object, - processExecutor, - NullLoggerFactory.Instance); - } - - private void SetupDoctor(DoctorContext context, DoctorReport report) - { - _doctorService.Setup(x => x.GetContextAsync(It.IsAny())) - .ReturnsAsync(context); - _doctorService.Setup(x => x.RunDoctorAsync(It.IsAny(), It.IsAny?>())) - .ReturnsAsync(report); - _doctorService.Setup(x => x.GetDotNetExecutablePath()) - .Returns("/usr/local/share/dotnet/dotnet"); - } - - private static ProcessResult CreateToolListResult(ProcessRequest request, string output) - { - if (request.Arguments.Length >= 2 && - request.Arguments[0] == "tool" && - request.Arguments[1] == "list") - { - return new ProcessResult(0, output, string.Empty, TimeSpan.Zero, ProcessState.Completed); - } - - return new ProcessResult(1, string.Empty, $"Unexpected command: {request.CommandLine}", TimeSpan.Zero, ProcessState.Failed); - } - - private static DoctorContext CreateDoctorContext(string activeSdkVersion) => new( - WorkingDirectory: "/Users/test/code/MAUI.Sherpa", - DotNetSdkPath: "/usr/local/share/dotnet", - GlobalJsonPath: "/Users/test/code/MAUI.Sherpa/global.json", - PinnedSdkVersion: "10.0.100", - PinnedWorkloadSetVersion: null, - EffectiveFeatureBand: "10.0.100", - IsPreviewSdk: false, - ActiveSdkVersion: activeSdkVersion, - RollForwardPolicy: "latestPatch", - ResolvedSdkVersion: activeSdkVersion); - - private static DoctorReport CreateDoctorReport(DoctorContext context, params DependencyStatus[] dependencies) => new( - context, - [new SdkVersionInfo(context.ActiveSdkVersion ?? "10.0.103", "10.0.100", 10, 0, false)], - AvailableSdkVersions: null, - InstalledWorkloadSetVersion: null, - AvailableWorkloadSetVersions: null, - Manifests: [], - Dependencies: dependencies, - DateTime.UtcNow); -} diff --git a/tests/MauiSherpa.Core.Tests/Services/ProfilingSessionStorageServiceTests.cs b/tests/MauiSherpa.Core.Tests/Services/ProfilingSessionStorageServiceTests.cs new file mode 100644 index 00000000..63c0bd7d --- /dev/null +++ b/tests/MauiSherpa.Core.Tests/Services/ProfilingSessionStorageServiceTests.cs @@ -0,0 +1,302 @@ +using System.IO.Compression; +using FluentAssertions; +using MauiSherpa.Core.Interfaces; +using MauiSherpa.Core.Models.Profiling; +using MauiSherpa.Core.Services; +using Moq; + +namespace MauiSherpa.Core.Tests.Services; + +public class ProfilingSessionStorageServiceTests : IDisposable +{ + private readonly string _testRoot; + private readonly string _sessionRoot; + private readonly string _externalRoot; + private readonly InMemoryEncryptedSettingsService _settings = new(); + private readonly ProfilingArtifactLibraryService _artifactLibrary; + private readonly ProfilingSessionStorageService _service; + + public ProfilingSessionStorageServiceTests() + { + _testRoot = Path.Combine(Path.GetTempPath(), $"maui-sherpa-profile-sessions-{Guid.NewGuid():N}"); + _sessionRoot = Path.Combine(_testRoot, "sessions"); + _externalRoot = Path.Combine(_testRoot, "external"); + var libraryRoot = Path.Combine(_testRoot, "library"); + var logger = new Mock(); + + _artifactLibrary = new ProfilingArtifactLibraryService(_settings, logger.Object, libraryRoot); + _service = new ProfilingSessionStorageService(logger.Object, _artifactLibrary, _sessionRoot); + } + + [Fact] + public async Task SaveMauiProfileSessionAsync_UsesCliArtifactsAndSynchronizesLibrary() + { + var primaryPath = CreateExternalArtifact("capture.speedscope.json"); + var rawTracePath = CreateExternalArtifact("capture.nettrace"); + CreateExternalArtifact("unreported.gcdump"); + var startedAt = DateTimeOffset.Parse("2026-02-20T10:00:00Z"); + var completedAt = startedAt.AddSeconds(12); + var request = new MauiProfileRequest + { + ProjectPath = "/work/My App.csproj", + Platform = ProfilingTargetPlatform.Android, + DeviceId = "emulator-5554", + DeviceName = "Pixel 9", + IsEmulator = true, + Mode = MauiProfileMode.Interaction, + Format = MauiProfileOutputFormat.Speedscope, + OutputPath = Path.Combine(_externalRoot, "requested.nettrace") + }; + var result = new MauiProfileResult + { + ProjectPath = request.ProjectPath, + ProjectName = "My App", + Framework = "net10.0-android", + Platform = "android", + DeviceId = request.DeviceId, + DeviceName = request.DeviceName!, + Configuration = "Release", + Format = "speedscope", + OutputPath = primaryPath, + RawTracePath = rawTracePath, + DiagnosticPort = 9300, + StartedAtUtc = startedAt, + CompletedAtUtc = completedAt + }; + + var manifest = await _service.SaveMauiProfileSessionAsync( + "session-1", + request, + result, + cliVersion: "1.2.3"); + + manifest.SchemaVersion.Should().Be(2); + manifest.Status.Should().Be(ProfilingSessionStatus.Completed); + manifest.CaptureKinds.Should().Equal(ProfilingCaptureKind.Interaction); + manifest.Target.Kind.Should().Be(ProfilingTargetKind.Emulator); + manifest.MauiProfile.Should().NotBeNull(); + manifest.MauiProfile!.Mode.Should().Be(MauiProfileMode.Interaction); + manifest.MauiProfile.Format.Should().Be(MauiProfileOutputFormat.Speedscope); + manifest.MauiProfile.CliVersion.Should().Be("1.2.3"); + manifest.MauiProfile.RawTraceFileName.Should().Be("capture.nettrace"); + manifest.Artifacts.Select(x => x.FileName).Should().BeEquivalentTo( + ["capture.speedscope.json", "capture.nettrace"]); + manifest.Artifacts.Should().OnlyContain(x => x.Kind == ProfilingArtifactKind.Trace); + File.Exists(Path.Combine(manifest.DirectoryPath!, "capture.speedscope.json")).Should().BeTrue(); + File.Exists(Path.Combine(manifest.DirectoryPath!, "capture.nettrace")).Should().BeTrue(); + File.Exists(Path.Combine(manifest.DirectoryPath!, "unreported.gcdump")).Should().BeFalse(); + File.Exists(Path.Combine(manifest.DirectoryPath!, "session.json.pending")).Should().BeFalse(); + + var libraryEntries = await _artifactLibrary.GetArtifactsAsync(); + libraryEntries.Should().HaveCount(2); + libraryEntries.Select(x => x.Metadata.Id).Should().BeEquivalentTo( + ["session-1:capture.speedscope.json", "session-1:capture.nettrace"]); + libraryEntries.Should().OnlyContain(x => x.Metadata.SessionId == "session-1"); + } + + [Fact] + public async Task SaveMauiProfileSessionAsync_AcceptsRecoveredResultAndDropsIntermediateFiles() + { + var sessionDirectory = _service.GetSessionDirectoryPath("recovered-1"); + Directory.CreateDirectory(sessionDirectory); + var primaryPath = Path.Combine(sessionDirectory, "capture.mibc"); + var rawTracePath = Path.Combine(sessionDirectory, "capture.nettrace"); + var intermediatePath = Path.Combine(sessionDirectory, "capture.etlx"); + await File.WriteAllTextAsync(primaryPath, "mibc"); + await File.WriteAllTextAsync(rawTracePath, "trace"); + await File.WriteAllTextAsync(intermediatePath, "index"); + + var request = new MauiProfileRequest + { + ProjectPath = "/work/MauiApp.csproj", + Platform = ProfilingTargetPlatform.Android, + DeviceId = "emulator-5554", + DeviceName = "emulator-5554", + IsEmulator = true, + Mode = MauiProfileMode.Startup, + Format = MauiProfileOutputFormat.Mibc, + OutputPath = rawTracePath + }; + var result = MauiProfileArtifactRecovery.TryRecover( + request, + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow); + + result.Should().NotBeNull(); + + var manifest = await _service.SaveMauiProfileSessionAsync("recovered-1", request, result!); + + manifest.Status.Should().Be(ProfilingSessionStatus.Completed); + manifest.MauiProfile!.Format.Should().Be(MauiProfileOutputFormat.Mibc); + manifest.MauiProfile.Framework.Should().BeNull(); + manifest.Project!.TargetFramework.Should().BeNull(); + manifest.MauiProfile.RawTraceFileName.Should().Be("capture.nettrace"); + manifest.Artifacts.Select(x => x.FileName).Should().BeEquivalentTo( + ["capture.mibc", "capture.nettrace"]); + File.Exists(intermediatePath).Should().BeFalse(); + File.Exists(primaryPath).Should().BeTrue(); + File.Exists(rawTracePath).Should().BeTrue(); + } + + [Theory] + [InlineData("standalone.nettrace", ProfilingArtifactKind.Trace)] + [InlineData("standalone.speedscope.json", ProfilingArtifactKind.Trace)] + [InlineData("standalone.mibc", ProfilingArtifactKind.Mibc)] + [InlineData("standalone.gcdump", ProfilingArtifactKind.GcDump)] + public async Task ImportArtifactAsync_ClassifiesAndIndexesSupportedArtifacts( + string fileName, + ProfilingArtifactKind expectedKind) + { + var sourcePath = CreateExternalArtifact(fileName); + + var manifest = await _service.ImportArtifactAsync(sourcePath); + + manifest.SchemaVersion.Should().Be(2); + manifest.MauiProfile.Should().BeNull(); + manifest.Artifacts.Should().ContainSingle(); + manifest.Artifacts[0].Kind.Should().Be(expectedKind); + File.Exists(Path.Combine(manifest.DirectoryPath!, fileName)).Should().BeTrue(); + + var libraryEntries = await _artifactLibrary.GetArtifactsAsync(); + libraryEntries.Should().ContainSingle(); + libraryEntries[0].Metadata.Kind.Should().Be(expectedKind); + libraryEntries[0].Metadata.SessionId.Should().Be(manifest.Id); + } + + [Fact] + public async Task GetSessionAsync_LoadsLegacyManifestWithoutCliMetadata() + { + const string sessionId = "legacy-session"; + var sessionDirectory = Path.Combine(_sessionRoot, sessionId); + Directory.CreateDirectory(sessionDirectory); + await File.WriteAllTextAsync( + Path.Combine(sessionDirectory, "session.json"), + LegacyManifestJson(sessionId, "Legacy Session")); + + var manifest = await _service.GetSessionAsync(sessionId); + + manifest.Should().NotBeNull(); + manifest!.SchemaVersion.Should().Be(1); + manifest.MauiProfile.Should().BeNull(); + manifest.Target.Platform.Should().Be(ProfilingTargetPlatform.MacCatalyst); + manifest.Options.LaunchMode.Should().Be(ProfilingCaptureLaunchMode.Launch); + manifest.CaptureKinds.Should().Equal(ProfilingCaptureKind.Cpu, ProfilingCaptureKind.Memory); + } + + [Fact] + public async Task ImportSessionAsync_PreservesLegacyArchiveAndAllocatesNewIdOnCollision() + { + const string collidingId = "shared-session"; + await _service.SaveSessionAsync(CreateManifest(collidingId, "Existing Session")); + + var archiveSource = Path.Combine(_testRoot, "legacy-archive"); + Directory.CreateDirectory(archiveSource); + await File.WriteAllTextAsync( + Path.Combine(archiveSource, "session.json"), + LegacyManifestJson(collidingId, "Legacy Archive")); + await File.WriteAllBytesAsync( + Path.Combine(archiveSource, "capture.nettrace"), + [1, 2, 3, 4]); + var archivePath = Path.Combine(_testRoot, "legacy-session.zip"); + ZipFile.CreateFromDirectory(archiveSource, archivePath); + + var imported = await _service.ImportSessionAsync(archivePath); + + imported.Should().NotBeNull(); + imported!.Id.Should().NotBe(collidingId); + imported.SchemaVersion.Should().Be(1); + imported.MauiProfile.Should().BeNull(); + File.Exists(Path.Combine(imported.DirectoryPath!, "capture.nettrace")).Should().BeTrue(); + + var sessions = await _service.GetSessionsAsync(); + sessions.Select(x => x.Id).Should().Contain([collidingId, imported.Id]); + var libraryEntries = await _artifactLibrary.GetArtifactsAsync( + new ProfilingArtifactLibraryQuery(SessionId: imported.Id)); + libraryEntries.Should().ContainSingle(); + libraryEntries[0].Metadata.Id.Should().Be($"{imported.Id}:capture.nettrace"); + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + Directory.Delete(_testRoot, recursive: true); + } + + private string CreateExternalArtifact(string fileName) + { + Directory.CreateDirectory(_externalRoot); + var path = Path.Combine(_externalRoot, fileName); + File.WriteAllBytes(path, [1, 2, 3, 4]); + return path; + } + + private static ProfilingSessionManifest CreateManifest(string id, string name) => new() + { + Id = id, + Name = name, + Status = ProfilingSessionStatus.Completed, + CreatedAt = DateTimeOffset.Parse("2026-02-20T10:00:00Z"), + CompletedAt = DateTimeOffset.Parse("2026-02-20T10:00:01Z"), + Target = new ProfilingSessionTarget + { + Platform = ProfilingTargetPlatform.Android, + Kind = ProfilingTargetKind.Emulator, + Identifier = "emulator-5554", + DisplayName = "Pixel" + }, + CaptureKinds = [ProfilingCaptureKind.Startup], + Options = new ProfilingSessionOptions() + }; + + private static string LegacyManifestJson(string id, string name) => $$""" + { + "id": "{{id}}", + "name": "{{name}}", + "status": "completed", + "createdAt": "2026-02-20T10:00:00+00:00", + "completedAt": "2026-02-20T10:00:01+00:00", + "target": { + "platform": "macCatalyst", + "kind": "desktop", + "identifier": "legacy-host", + "displayName": "Legacy Host" + }, + "captureKinds": [ "cpu", "memory" ], + "options": { + "launchMode": "launch", + "diagnosticPort": 9000, + "suspendAtStartup": true, + "scenario": "launch" + }, + "artifacts": [ + { + "fileName": "capture.nettrace", + "kind": "trace", + "sizeBytes": 4, + "displayName": "Trace" + } + ] + } + """; + + private sealed class InMemoryEncryptedSettingsService : IEncryptedSettingsService + { + public MauiSherpaSettings Current { get; private set; } = new(); + + public event Action? OnSettingsChanged; + + public Task GetSettingsAsync() => Task.FromResult(Current); + + public Task SaveSettingsAsync(MauiSherpaSettings settings) + { + Current = settings; + OnSettingsChanged?.Invoke(); + return Task.CompletedTask; + } + + public Task UpdateSettingsAsync(Func transform) => + SaveSettingsAsync(transform(Current)); + + public Task SettingsExistAsync() => Task.FromResult(true); + } +}