diff --git a/App.xaml.cs b/App.xaml.cs index 4916d2a..06dc022 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -63,50 +63,22 @@ public App() protected override void OnStartup(StartupEventArgs e) { // Parse command line arguments early so special startup modes can short-circuit normal flow. - bool startMinimized = false; - bool isAutostart = false; - bool isSmokeTest = false; - bool registerLaunchTask = false; - bool launchedViaTask = false; -#if DEBUG - bool isTestMode = false; -#endif + var startupMode = StartupMode.Parse(e.Args); bool effectiveStartMinimized = false; ApplicationSettingsModel? loadedSettings = null; - foreach (var arg in e.Args) + effectiveStartMinimized = startupMode.StartMinimized; + + if (startupMode.IsSmokeTest) { - switch (arg.ToLowerInvariant()) - { -#if DEBUG - case "--test": - isTestMode = true; - break; -#endif - case "--smoke-test": - isSmokeTest = true; - break; - case "--start-minimized": - startMinimized = true; - break; - case "--autostart": - isAutostart = true; - break; - case "--startup": // Alternative startup argument - isAutostart = true; - startMinimized = true; - break; - case RegisterLaunchTaskArgument: - registerLaunchTask = true; - break; - case LaunchedViaTaskArgument: - launchedViaTask = true; - break; - } + var smokeLogger = this.ServiceProvider.GetRequiredService>(); + var smokeTestResult = this.RunSmokeTestWithTimeout(smokeLogger, TimeSpan.FromSeconds(10)); + Environment.ExitCode = smokeTestResult; + this.Shutdown(smokeTestResult); + Environment.Exit(smokeTestResult); + return; } - effectiveStartMinimized = startMinimized; - // Set up global exception handlers first AppDomain.CurrentDomain.UnhandledException += this.OnUnhandledException; this.DispatcherUnhandledException += this.OnDispatcherUnhandledException; @@ -130,14 +102,14 @@ protected override void OnStartup(StartupEventArgs e) } else { - if (launchedViaTask) + if (startupMode.LaunchedViaTask) { logger.LogError("Application was launched via managed task marker but is still not elevated."); } #if DEBUG - else if (!isSmokeTest && !isTestMode) + else if (!startupMode.IsTestMode) #else - else if (!isSmokeTest) + else #endif { var launchedElevatedInstance = Task.Run(async () => await elevatedTaskService.TryRunLaunchTaskAsync()).GetAwaiter().GetResult(); @@ -148,7 +120,7 @@ protected override void OnStartup(StartupEventArgs e) return; } - if (!registerLaunchTask) + if (!startupMode.RegisterLaunchTask) { logger.LogInformation("Managed elevated launch task is unavailable. Requesting one-time elevation to bootstrap persistent launch."); var restartInitiated = Task.Run(async () => await elevationService.RestartWithElevation(new[] { RegisterLaunchTaskArgument })).GetAwaiter().GetResult(); @@ -160,9 +132,9 @@ protected override void OnStartup(StartupEventArgs e) } #if DEBUG - if (!isSmokeTest && !isTestMode) + if (!startupMode.IsTestMode) #else - if (!isSmokeTest) + if (true) #endif { logger.LogError("ThreadPilot requires administrator privileges and cannot continue without elevation."); @@ -170,33 +142,28 @@ protected override void OnStartup(StartupEventArgs e) this.Shutdown(1); return; } - - logger.LogWarning("Application is running without administrator privileges in smoke test mode."); } // Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff. - if (!isSmokeTest) + bool createdNew; + this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); + if (!createdNew) { - bool createdNew; - this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); - if (!createdNew) - { - System.Windows.MessageBox.Show( - "ThreadPilot is already running.", - "Instance already open", - MessageBoxButton.OK, - MessageBoxImage.Information); + System.Windows.MessageBox.Show( + "ThreadPilot is already running.", + "Instance already open", + MessageBoxButton.OK, + MessageBoxImage.Information); - this.Shutdown(); - return; - } + this.Shutdown(); + return; } base.OnStartup(e); // Check for test mode #if DEBUG - if (isTestMode) + if (startupMode.IsTestMode) { // Run in console test mode AllocConsole(); @@ -209,13 +176,6 @@ protected override void OnStartup(StartupEventArgs e) } #endif - if (isSmokeTest) - { - var smokeTestResult = Task.Run(async () => await this.RunSmokeTestAsync(logger)).GetAwaiter().GetResult(); - this.Shutdown(smokeTestResult); - return; - } - try { var settingsService = this.ServiceProvider.GetRequiredService(); @@ -226,7 +186,7 @@ protected override void OnStartup(StartupEventArgs e) var settings = settingsService.Settings; loadedSettings = settings; localizationService.ApplyLanguage(settings.Language); - effectiveStartMinimized = startMinimized || settings.StartMinimized; + effectiveStartMinimized = startupMode.StartMinimized || settings.StartMinimized; var useDarkTheme = settings.HasUserThemePreference ? settings.UseDarkTheme : themeService.GetSystemUsesDarkTheme(); @@ -258,7 +218,7 @@ protected override void OnStartup(StartupEventArgs e) throw new InvalidOperationException("MainWindow could not be created"); } - var startupWindowBehavior = StartupWindowBehavior.Resolve(isAutostart, effectiveStartMinimized); + var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized); var showStartupSuggestion = loadedSettings != null && StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior); mainWindow.ConfigureStartupMode( @@ -301,16 +261,33 @@ protected override void OnStartup(StartupEventArgs e) } } - private async Task RunSmokeTestAsync(ILogger logger) + private int RunSmokeTestWithTimeout(ILogger logger, TimeSpan timeout) + { + var smokeTestTask = Task.Run(() => this.RunSmokeTest(logger)); + if (smokeTestTask.Wait(timeout)) + { + return smokeTestTask.GetAwaiter().GetResult(); + } + + logger.LogError("ThreadPilot smoke test timed out after {TimeoutSeconds} seconds", timeout.TotalSeconds); + return 2; + } + + private int RunSmokeTest(ILogger logger) { try { logger.LogInformation("Starting ThreadPilot smoke test"); - var settingsService = this.ServiceProvider.GetRequiredService(); - await settingsService.LoadSettingsAsync().ConfigureAwait(false); - _ = this.ServiceProvider.GetRequiredService(); - _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + _ = this.ServiceProvider.GetRequiredService(); + + if (!System.IO.Directory.Exists(AppContext.BaseDirectory)) + { + throw new InvalidOperationException("Application base directory was not found."); + } logger.LogInformation("ThreadPilot smoke test completed successfully"); return 0; @@ -322,6 +299,55 @@ private async Task RunSmokeTestAsync(ILogger logger) } } + private readonly struct StartupMode + { + public bool StartMinimized { get; init; } + + public bool IsAutostart { get; init; } + + public bool IsSmokeTest { get; init; } + + public bool RegisterLaunchTask { get; init; } + + public bool LaunchedViaTask { get; init; } + + public bool IsTestMode { get; init; } + + public static StartupMode Parse(IEnumerable args) + { + var mode = default(StartupMode); + foreach (var arg in args) + { + switch (arg.ToLowerInvariant()) + { + case "--test": + mode = mode with { IsTestMode = true }; + break; + case "--smoke-test": + mode = mode with { IsSmokeTest = true }; + break; + case "--start-minimized": + mode = mode with { StartMinimized = true }; + break; + case "--autostart": + mode = mode with { IsAutostart = true }; + break; + case "--startup": + mode = mode with { IsAutostart = true, StartMinimized = true }; + break; + case RegisterLaunchTaskArgument: + mode = mode with { RegisterLaunchTask = true }; + break; + case LaunchedViaTaskArgument: + mode = mode with { LaunchedViaTask = true }; + break; + } + } + + return mode; + } + } + protected override void OnExit(ExitEventArgs e) { AppDomain.CurrentDomain.UnhandledException -= this.OnUnhandledException; diff --git a/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs b/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs new file mode 100644 index 0000000..882acdd --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/AppSmokeTestStartupTests.cs @@ -0,0 +1,78 @@ +namespace ThreadPilot.Core.Tests +{ + public sealed class AppSmokeTestStartupTests + { + [Fact] + public void OnStartup_HandlesSmokeTestBeforeElevationSingleInstanceAndWindowStartup() + { + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); + + var smokeTestBranchIndex = source.IndexOf("if (startupMode.IsSmokeTest)", StringComparison.Ordinal); + var elevationIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal); + var mutexIndex = source.IndexOf("Global\\\\ThreadPilot_SingleInstance", StringComparison.Ordinal); + var baseStartupIndex = source.IndexOf("base.OnStartup(e);", StringComparison.Ordinal); + var mainWindowIndex = source.IndexOf("GetRequiredService", StringComparison.Ordinal); + + Assert.NotEqual(-1, smokeTestBranchIndex); + Assert.True(smokeTestBranchIndex < elevationIndex); + Assert.True(smokeTestBranchIndex < mutexIndex); + Assert.True(smokeTestBranchIndex < baseStartupIndex); + Assert.True(smokeTestBranchIndex < mainWindowIndex); + } + + [Fact] + public void SmokeTestMode_ExitsTheProcessAfterShutdownToAvoidDispatcherOrTimerHangs() + { + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); + + var smokeTestBranch = ExtractSection( + source, + "if (startupMode.IsSmokeTest)", + " // Set up global exception handlers first"); + + Assert.Contains("this.Shutdown(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal); + Assert.Contains("Environment.Exit(smokeTestResult);", smokeTestBranch, StringComparison.Ordinal); + } + + [Fact] + public void RunSmokeTest_DoesNotResolveUiViewModelsOrMainWindow() + { + var source = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "App.xaml.cs")); + var smokeTestMethod = ExtractSection( + source, + "private int RunSmokeTest", + "protected override void OnExit"); + + Assert.DoesNotContain("ProcessViewModel", smokeTestMethod, StringComparison.Ordinal); + Assert.DoesNotContain("PowerPlanViewModel", smokeTestMethod, StringComparison.Ordinal); + Assert.DoesNotContain("MainWindow", smokeTestMethod, StringComparison.Ordinal); + } + + private static string ExtractSection(string source, string startMarker, string endMarker) + { + var startIndex = source.IndexOf(startMarker, StringComparison.Ordinal); + Assert.NotEqual(-1, startIndex); + + var endIndex = source.IndexOf(endMarker, startIndex, StringComparison.Ordinal); + Assert.NotEqual(-1, endIndex); + + return source[startIndex..endIndex]; + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "ThreadPilot.csproj"))) + { + directory = directory.Parent; + } + + if (directory == null) + { + throw new InvalidOperationException("Repository root was not found."); + } + + return directory.FullName; + } + } +}