diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 4e9a8f8..229adf0 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -176,6 +176,10 @@ VSTest can print `Test run for ` **before** it opens the file. That banner **Project file** is used for build/run/watch (usually the app `.csproj`). **Test project / solution** (optional) targets `dotnet test` — leave blank to auto-detect a `.sln`/`.slnx` in the repo root or `*Tests.csproj` files. Running tests against the app `.csproj` only restores packages and does not execute tests. +Paths that do not resolve under the project's **Root folder** (or do not exist) are rejected on load/save so another project's relative test path cannot stick. When blank, Settings shows the effective auto-detect target as a hint. + +Projects Settings is capability-aware: with **Run mode = None**, launch profile / site URL / restart-app controls are hidden; **Tests** and build/watch options remain. Preferred site URL appears only when launch profiles declare `applicationUrl`. The **Build CLI** column shows muted inline **Launch behaviour** help and a **Detected application** summary (from the same capability/evidence flags as the controls) so empty space next to Azure / Cursor agent content explains why launch/site fields appear or stay hidden — presentation only; no new persisted settings. + Output uses `--verbosity normal` and a detailed console logger (per-test pass/fail lines plus a summary in the finish banner). **Stop processes locking build output** applies before builds and when a full test rebuild is needed (or on lock-error retry). It is not used for the normal `--no-build` test path while the site stays up. Enable it when the app is started outside Build Monitor and locks `bin` output during rebuilds. diff --git a/src/BuildMonitor.Tests/SettingsBuildCliContextPresenterTests.cs b/src/BuildMonitor.Tests/SettingsBuildCliContextPresenterTests.cs new file mode 100644 index 0000000..e36fe83 --- /dev/null +++ b/src/BuildMonitor.Tests/SettingsBuildCliContextPresenterTests.cs @@ -0,0 +1,115 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Tests; + +public sealed class SettingsBuildCliContextPresenterTests +{ + [Fact] + public void Web_runnable_explains_launch_and_reports_web_endpoint() + { + var caps = SettingsProjectCapabilityPolicy.Evaluate( + LocalProject(ProjectRunMode.Watch), + launchProfilesAvailable: true, + siteUrlApplicable: true); + + var view = SettingsBuildCliContextPresenter.Build( + caps, + launchProfilesDetected: true, + webEndpointDetected: true, + selectedOrPreferredLaunchProfile: "https", + runMode: ProjectRunMode.Watch); + + Assert.True(view.ShowLaunchBehaviour); + Assert.Contains("Launch profile", view.LaunchBehaviourBody, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Preferred site URL", view.LaunchBehaviourBody, StringComparison.OrdinalIgnoreCase); + Assert.True(view.ShowDetection); + Assert.Contains(view.DetectionLines, l => l.Contains("Web endpoint", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(view.DetectionLines, l => l.Contains("Launch profile: https", StringComparison.Ordinal)); + Assert.Contains(view.DetectionLines, l => l.Contains("launchSettings.json", StringComparison.Ordinal)); + Assert.DoesNotContain(view.DetectionLines, l => l.Contains("not applicable", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Non_web_does_not_claim_web_endpoint_and_explains_hidden_site_url() + { + var caps = SettingsProjectCapabilityPolicy.Evaluate( + LocalProject(ProjectRunMode.Run), + launchProfilesAvailable: true, + siteUrlApplicable: false); + + var view = SettingsBuildCliContextPresenter.Build( + caps, + launchProfilesDetected: true, + webEndpointDetected: false, + selectedOrPreferredLaunchProfile: "BuildMonitor.TrayApp", + runMode: ProjectRunMode.Run); + + Assert.True(view.ShowLaunchBehaviour); + Assert.Contains("Site URL settings stay hidden", view.LaunchBehaviourBody, StringComparison.Ordinal); + Assert.True(view.ShowDetection); + Assert.Contains(view.DetectionLines, l => l.Contains("Desktop / non-web", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(view.DetectionLines, l => l.Contains("No web endpoint", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(view.DetectionLines, l => l.Contains("Site URL settings are not applicable", StringComparison.Ordinal)); + Assert.DoesNotContain(view.DetectionLines, l => l.Contains("Web endpoint available", StringComparison.Ordinal)); + } + + [Fact] + public void Hidden_controls_and_explanatory_state_agree_for_run_mode_none() + { + var caps = SettingsProjectCapabilityPolicy.Evaluate( + LocalProject(ProjectRunMode.None), + launchProfilesAvailable: true, + siteUrlApplicable: true); + + Assert.False(caps.LaunchProfilesAvailable); + Assert.False(caps.SiteUrlApplicable); + + var view = SettingsBuildCliContextPresenter.Build( + caps, + launchProfilesDetected: true, + webEndpointDetected: true, + selectedOrPreferredLaunchProfile: "https", + runMode: ProjectRunMode.None); + + Assert.Contains("does not launch", view.LaunchBehaviourBody, StringComparison.OrdinalIgnoreCase); + Assert.Contains("stay hidden", view.LaunchBehaviourBody, StringComparison.OrdinalIgnoreCase); + Assert.Contains(view.DetectionLines, l => l.Contains("Build / monitor only", StringComparison.Ordinal)); + Assert.Contains(view.DetectionLines, l => l.Contains("are not shown", StringComparison.OrdinalIgnoreCase)); + // Capability says launch/site UI hidden — detection must not present them as active controls. + Assert.DoesNotContain(view.DetectionLines, l => l.StartsWith("Launch profile:", StringComparison.Ordinal)); + Assert.DoesNotContain(view.DetectionLines, l => l.StartsWith("Site URL: resolved", StringComparison.Ordinal)); + } + + [Fact] + public void Non_web_evidence_must_not_claim_web_when_detection_flag_false() + { + var caps = SettingsProjectCapabilityPolicy.Evaluate( + LocalProject(ProjectRunMode.Watch), + launchProfilesAvailable: false, + siteUrlApplicable: false); + + var view = SettingsBuildCliContextPresenter.Build( + caps, + launchProfilesDetected: false, + webEndpointDetected: false, + selectedOrPreferredLaunchProfile: null, + runMode: ProjectRunMode.Watch); + + Assert.DoesNotContain(view.DetectionLines, l => l.Contains("Web endpoint available", StringComparison.Ordinal)); + Assert.Contains(view.DetectionLines, l => l.Contains("No web endpoint", StringComparison.OrdinalIgnoreCase)); + } + + private static MonitoredProjectSettings LocalProject(ProjectRunMode mode) => new() + { + Id = "p1", + DisplayName = "Sample", + Local = new LocalProjectAttachment + { + RootFolder = @"C:\src\Sample", + ProjectFile = "Sample.csproj", + RunOptions = { RunMode = mode } + } + }; +} diff --git a/src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs b/src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs new file mode 100644 index 0000000..5c7f99e --- /dev/null +++ b/src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs @@ -0,0 +1,174 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Rules; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Tests; + +public sealed class SettingsProjectCapabilityPolicyTests +{ + [Fact] + public void RunMode_None_hides_launch_site_and_restart_keeps_tests() + { + var project = LocalProject(ProjectRunMode.None); + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: true, + siteUrlApplicable: true); + + Assert.True(caps.HasLocalAttachment); + Assert.True(caps.RunModeNone); + Assert.False(caps.Runnable); + Assert.False(caps.LaunchProfilesAvailable); + Assert.False(caps.SiteUrlApplicable); + Assert.False(caps.RestartApplicable); + Assert.False(caps.WatchRestartApplicable); + Assert.True(caps.WatchApplicable); + Assert.True(caps.TestsApplicable); + } + + [Fact] + public void Non_web_runnable_shows_launch_not_site_url() + { + var project = LocalProject(ProjectRunMode.Run); + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: true, + siteUrlApplicable: false); + + Assert.True(caps.Runnable); + Assert.True(caps.LaunchProfilesAvailable); + Assert.False(caps.SiteUrlApplicable); + Assert.True(caps.RestartApplicable); + Assert.True(caps.TestsApplicable); + } + + [Fact] + public void Web_runnable_shows_launch_and_site_url() + { + var project = LocalProject(ProjectRunMode.Watch); + var caps = SettingsProjectCapabilityPolicy.Evaluate( + project, + launchProfilesAvailable: true, + siteUrlApplicable: true); + + Assert.True(caps.LaunchProfilesAvailable); + Assert.True(caps.SiteUrlApplicable); + Assert.True(caps.WatchRestartApplicable); + Assert.True(caps.RestartApplicable); + } + + [Fact] + public void Azure_only_project_hides_local_capabilities() + { + var project = new MonitoredProjectSettings + { + DisplayName = "Azure only", + Local = null, + Azure = new AzureDevOpsProjectAttachment { ConnectionId = "c1" } + }; + + var caps = SettingsProjectCapabilityPolicy.Evaluate(project, true, true); + Assert.False(caps.HasLocalAttachment); + Assert.False(caps.TestsApplicable); + Assert.False(caps.Runnable); + } + + [Fact] + public void Capability_visibility_does_not_require_hard_restart_classification() + { + // Presentation-only AppBehavior change remains Presentation under #87. + var before = new AppSettings + { + Projects = [LocalProject(ProjectRunMode.None)], + AppBehavior = new AppBehaviorSettings { TrayMenuLayout = TrayMenuLayout.ByOperation } + }; + var after = System.Text.Json.JsonSerializer.Deserialize( + System.Text.Json.JsonSerializer.Serialize(before))!; + after.AppBehavior.TrayMenuLayout = TrayMenuLayout.ByProject; + + Assert.Equal( + SettingsApplyImpact.Presentation, + SettingsApplyImpactClassifier.Classify(before, after)); + } + + private static MonitoredProjectSettings LocalProject(ProjectRunMode mode) => new() + { + Id = "p1", + DisplayName = "Sample", + Local = new LocalProjectAttachment + { + RootFolder = @"C:\src\Sample", + ProjectFile = "Sample.csproj", + RunOptions = { RunMode = mode } + } + }; +} + +public sealed class TestProjectPathRulesTests +{ + [Fact] + public void Empty_test_path_is_valid() + { + Assert.True(TestProjectPathRules.IsValidForRoot(@"C:\src\BuildMonitor", "")); + Assert.Equal("", TestProjectPathRules.SanitizeForRoot(@"C:\src\BuildMonitor", " ")); + } + + [Fact] + public void Foreign_relative_path_is_rejected() + { + var root = Path.Combine(Path.GetTempPath(), "bm-root-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var foreign = @"WitherbyConnect.Tests\WitherbyConnect.Tests.csproj"; + Assert.False(TestProjectPathRules.IsValidForRoot(root, foreign)); + Assert.Equal("", TestProjectPathRules.SanitizeForRoot(root, foreign)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Owned_existing_test_project_is_accepted() + { + var root = Path.Combine(Path.GetTempPath(), "bm-root-" + Guid.NewGuid().ToString("N")); + var testsDir = Path.Combine(root, "src", "BuildMonitor.Tests"); + Directory.CreateDirectory(testsDir); + var testsProj = Path.Combine(testsDir, "BuildMonitor.Tests.csproj"); + File.WriteAllText(testsProj, ""); + try + { + var relative = Path.Combine("src", "BuildMonitor.Tests", "BuildMonitor.Tests.csproj"); + Assert.True(TestProjectPathRules.IsValidForRoot(root, relative)); + Assert.Equal(relative, TestProjectPathRules.SanitizeForRoot(root, relative)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Switching_projects_does_not_keep_foreign_test_path_on_sanitize() + { + var buildMonitor = new MonitoredProjectSettings + { + Id = "bm", + DisplayName = "BuildMonitor.TrayApp", + Local = new LocalProjectAttachment + { + RootFolder = @"C:\src\BuildMonitor", + ProjectFile = @"src\TrayApp\BuildMonitor.TrayApp.csproj", + TestProjectFile = @"WitherbyConnect.Tests\WitherbyConnect.Tests.csproj" + } + }; + + buildMonitor.Local.TestProjectFile = TestProjectPathRules.SanitizeForRoot( + buildMonitor.Local.RootFolder, + buildMonitor.Local.TestProjectFile); + + Assert.Equal(string.Empty, buildMonitor.Local.TestProjectFile); + } +} diff --git a/src/BuildMonitor.Tests/ThemeComboBoxStyleTests.cs b/src/BuildMonitor.Tests/ThemeComboBoxStyleTests.cs new file mode 100644 index 0000000..e8938ce --- /dev/null +++ b/src/BuildMonitor.Tests/ThemeComboBoxStyleTests.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; + +namespace BuildMonitor.Tests; + +/// +/// Guards the #89 regression where keyed Settings ComboBox styles omitted BasedOn +/// and dropped dark-theme chrome (light system background + light foreground). +/// +public sealed class ThemeComboBoxStyleTests +{ + [Fact] + public void Dark_and_light_ComboBox_theme_styles_set_both_background_and_foreground() + { + var dark = File.ReadAllText(FindRepoPath("src", "TrayApp", "Themes", "AppTheme.Dark.xaml")); + var light = File.ReadAllText(FindRepoPath("src", "TrayApp", "Themes", "AppTheme.Light.xaml")); + + AssertComboBoxThemeBrushes(dark); + AssertComboBoxThemeBrushes(light); + } + + [Fact] + public void SettingsFieldComboBox_style_is_based_on_theme_ComboBox() + { + var xaml = File.ReadAllText(FindRepoPath("src", "TrayApp", "SettingsWindow.xaml")); + Assert.Matches( + new Regex( + """x:Key="SettingsFieldComboBox"[^>]*BasedOn="\{StaticResource \{x:Type ComboBox\}\}""", + RegexOptions.Singleline), + xaml); + Assert.Matches( + new Regex( + """x:Key="SettingsFieldTextBox"[^>]*BasedOn="\{StaticResource \{x:Type TextBox\}\}""", + RegexOptions.Singleline), + xaml); + } + + private static void AssertComboBoxThemeBrushes(string themeXaml) + { + var match = Regex.Match( + themeXaml, + """]*>.*?""", + RegexOptions.Singleline); + Assert.True(match.Success, "ComboBox TargetType style missing from theme."); + var body = match.Value; + Assert.Contains("Background", body, StringComparison.Ordinal); + Assert.Contains("Foreground", body, StringComparison.Ordinal); + Assert.Contains("ThemeControlBrush", body, StringComparison.Ordinal); + Assert.Contains("ThemeForegroundBrush", body, StringComparison.Ordinal); + } + + private static string FindRepoPath(params string[] parts) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); + if (File.Exists(candidate)) + { + return candidate; + } + + dir = dir.Parent; + } + + throw new FileNotFoundException(string.Join(Path.DirectorySeparatorChar, parts)); + } +} diff --git a/src/Core/Rules/SettingsBuildCliContextPresenter.cs b/src/Core/Rules/SettingsBuildCliContextPresenter.cs new file mode 100644 index 0000000..07df947 --- /dev/null +++ b/src/Core/Rules/SettingsBuildCliContextPresenter.cs @@ -0,0 +1,162 @@ +using BuildMonitor.Core.Models; + +namespace BuildMonitor.Core.Rules; + +/// +/// Read-only Build CLI contextual copy for Projects Settings. Presentation only — +/// does not change capability detection or persisted settings. +/// +public sealed record SettingsBuildCliContextView( + bool ShowLaunchBehaviour, + string LaunchBehaviourTitle, + string LaunchBehaviourBody, + bool ShowDetection, + string DetectionTitle, + IReadOnlyList DetectionLines); + +/// +/// Builds muted inline help / detection summary for the Build CLI column from +/// plus launchSettings evidence. +/// +public static class SettingsBuildCliContextPresenter +{ + public static SettingsBuildCliContextView Build( + SettingsProjectCapabilities caps, + bool launchProfilesDetected, + bool webEndpointDetected, + string? selectedOrPreferredLaunchProfile, + ProjectRunMode runMode) + { + if (!caps.HasLocalAttachment) + { + return new SettingsBuildCliContextView( + ShowLaunchBehaviour: true, + LaunchBehaviourTitle: "Launch behaviour", + LaunchBehaviourBody: + "Associate a local folder to configure how BuildMonitor launches and runs this project.", + ShowDetection: false, + DetectionTitle: "Detected application", + DetectionLines: []); + } + + var launchBody = BuildLaunchBehaviourBody(caps, runMode); + var detectionLines = BuildDetectionLines( + caps, + launchProfilesDetected, + webEndpointDetected, + selectedOrPreferredLaunchProfile); + + return new SettingsBuildCliContextView( + ShowLaunchBehaviour: true, + LaunchBehaviourTitle: "Launch behaviour", + LaunchBehaviourBody: launchBody, + ShowDetection: detectionLines.Count > 0, + DetectionTitle: "Detected application", + DetectionLines: detectionLines); + } + + private static string BuildLaunchBehaviourBody( + SettingsProjectCapabilities caps, + ProjectRunMode runMode) + { + if (caps.RunModeNone) + { + return + "Run mode is None — BuildMonitor monitors and builds this project but does not launch it. " + + "Launch profile and site URL controls stay hidden until a launch mode is selected."; + } + + if (caps.LaunchProfilesAvailable && caps.SiteUrlApplicable) + { + return + "These settings control how BuildMonitor launches the app after a successful or manual build. " + + "Launch profile selects the configuration/environment. " + + "Preferred site URL chooses which discovered web endpoint to display or open when applicable."; + } + + if (caps.LaunchProfilesAvailable) + { + return + "These settings control how BuildMonitor launches the app after a successful or manual build. " + + "Launch profile selects the configuration/environment. " + + "Site URL settings stay hidden when no web endpoint is detected."; + } + + // Runnable but no launchSettings profiles (or empty discovery). + return runMode switch + { + ProjectRunMode.Watch => + "Watch mode rebuilds on change and can start the app when configured. " + + "No launch profiles were found under Properties/launchSettings.json.", + ProjectRunMode.Run => + "Run mode starts the app once after a successful build. " + + "No launch profiles were found under Properties/launchSettings.json.", + _ => + "These settings control how BuildMonitor launches the application after a successful or manual build." + }; + } + + private static IReadOnlyList BuildDetectionLines( + SettingsProjectCapabilities caps, + bool launchProfilesDetected, + bool webEndpointDetected, + string? selectedOrPreferredLaunchProfile) + { + if (caps.RunModeNone) + { + return + [ + "Build / monitor only (Run mode: None)", + "Launch profile and site URL controls are not shown", + webEndpointDetected + ? "Web endpoint present in launchSettings.json (unused while not launching)" + : "No web endpoint required for build-only monitoring" + ]; + } + + if (webEndpointDetected && caps.SiteUrlApplicable) + { + var lines = new List + { + "Web endpoint available", + }; + if (!string.IsNullOrWhiteSpace(selectedOrPreferredLaunchProfile)) + { + lines.Add($"Launch profile: {selectedOrPreferredLaunchProfile.Trim()}"); + } + else if (launchProfilesDetected) + { + lines.Add("Launch profiles found in launchSettings.json"); + } + + lines.Add("Site URL: resolved from launchSettings.json"); + return lines; + } + + // Non-web (or site URL not applicable while runnable). + var nonWeb = new List + { + "Desktop / non-web project", + "No web endpoint detected", + "Site URL settings are not applicable" + }; + + if (launchProfilesDetected && caps.LaunchProfilesAvailable) + { + if (!string.IsNullOrWhiteSpace(selectedOrPreferredLaunchProfile)) + { + nonWeb.Insert(1, $"Launch profile: {selectedOrPreferredLaunchProfile.Trim()}"); + } + else + { + nonWeb.Insert(1, "Launch profiles found (no applicationUrl)"); + } + } + else if (!launchProfilesDetected) + { + nonWeb.Insert(1, "No launchSettings.json profiles found"); + } + + return nonWeb; + } +} diff --git a/src/Core/Rules/SettingsProjectCapabilityPolicy.cs b/src/Core/Rules/SettingsProjectCapabilityPolicy.cs new file mode 100644 index 0000000..48194ef --- /dev/null +++ b/src/Core/Rules/SettingsProjectCapabilityPolicy.cs @@ -0,0 +1,60 @@ +using BuildMonitor.Core.Models; +using BuildMonitor.Core.Settings; + +namespace BuildMonitor.Core.Rules; + +/// +/// Capability flags for Projects Settings presentation. Derived from attachment + run mode + +/// launch-profile evidence — never from project display names. +/// +public sealed record SettingsProjectCapabilities( + bool HasLocalAttachment, + bool RunModeNone, + bool Runnable, + bool LaunchProfilesAvailable, + bool SiteUrlApplicable, + bool WatchApplicable, + bool RestartApplicable, + bool WatchRestartApplicable, + bool TestsApplicable); + +/// Computes which Settings controls are meaningful for the selected project. +public static class SettingsProjectCapabilityPolicy +{ + public static SettingsProjectCapabilities Evaluate( + MonitoredProjectSettings? project, + bool launchProfilesAvailable = false, + bool siteUrlApplicable = false) + { + var local = project?.Local; + if (local is null) + { + return new SettingsProjectCapabilities( + HasLocalAttachment: false, + RunModeNone: true, + Runnable: false, + LaunchProfilesAvailable: false, + SiteUrlApplicable: false, + WatchApplicable: false, + RestartApplicable: false, + WatchRestartApplicable: false, + TestsApplicable: false); + } + + var runMode = local.RunOptions.RunMode; + var runModeNone = runMode == ProjectRunMode.None; + var runnable = !runModeNone; + var watch = runMode == ProjectRunMode.Watch; + + return new SettingsProjectCapabilities( + HasLocalAttachment: true, + RunModeNone: runModeNone, + Runnable: runnable, + LaunchProfilesAvailable: runnable && launchProfilesAvailable, + SiteUrlApplicable: runnable && siteUrlApplicable, + WatchApplicable: true, + RestartApplicable: runnable, + WatchRestartApplicable: watch, + TestsApplicable: true); + } +} diff --git a/src/Core/Rules/TestProjectPathRules.cs b/src/Core/Rules/TestProjectPathRules.cs new file mode 100644 index 0000000..2d2f9dd --- /dev/null +++ b/src/Core/Rules/TestProjectPathRules.cs @@ -0,0 +1,55 @@ +namespace BuildMonitor.Core.Rules; + +/// +/// Validates that a persisted Test project / solution path belongs to the project's root folder. +/// Prevents cross-project relative paths (e.g. Witherby tests under a BuildMonitor root) from sticking. +/// +public static class TestProjectPathRules +{ + /// + /// Empty is valid (auto-discover). Otherwise the path must resolve under + /// and exist as a file when the root exists. + /// + public static bool IsValidForRoot(string? rootFolder, string? testProjectFile) + { + if (string.IsNullOrWhiteSpace(testProjectFile)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(rootFolder)) + { + return false; + } + + string full; + try + { + full = Path.IsPathRooted(testProjectFile) + ? Path.GetFullPath(testProjectFile) + : Path.GetFullPath(Path.Combine(rootFolder, testProjectFile)); + var rootFull = Path.GetFullPath(rootFolder); + var rootPrefix = rootFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + if (!full.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) + && !string.Equals(full, rootFull, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + catch + { + return false; + } + + return File.Exists(full); + } + + /// + /// Returns when valid; otherwise empty (auto-discover). + /// + public static string SanitizeForRoot(string? rootFolder, string? testProjectFile) => + IsValidForRoot(rootFolder, testProjectFile) + ? (testProjectFile ?? string.Empty).Trim() + : string.Empty; +} diff --git a/src/TrayApp/Services/LaunchProfileDiscovery.cs b/src/TrayApp/Services/LaunchProfileDiscovery.cs index 5b17349..89ec9ab 100644 --- a/src/TrayApp/Services/LaunchProfileDiscovery.cs +++ b/src/TrayApp/Services/LaunchProfileDiscovery.cs @@ -72,6 +72,54 @@ public static IReadOnlyList DiscoverProfiles(string rootFolder, string p return profiles[0]; } + /// + /// True when any launch profile declares applicationUrl (web/site-ready projects). + /// + public static bool AnyProfileHasApplicationUrl(string rootFolder, string projectFile) + { + var fullProjectPath = ResolveProjectPath(rootFolder, projectFile); + if (string.IsNullOrWhiteSpace(fullProjectPath) || !File.Exists(fullProjectPath)) + { + return false; + } + + var projectDir = Path.GetDirectoryName(fullProjectPath); + if (string.IsNullOrWhiteSpace(projectDir)) + { + return false; + } + + var launchSettingsPath = Path.Combine(projectDir, "Properties", "launchSettings.json"); + if (!File.Exists(launchSettingsPath)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(launchSettingsPath)); + if (!doc.RootElement.TryGetProperty("profiles", out var profiles)) + { + return false; + } + + foreach (var profile in profiles.EnumerateObject()) + { + if (profile.Value.TryGetProperty("applicationUrl", out var url) + && !string.IsNullOrWhiteSpace(url.GetString())) + { + return true; + } + } + } + catch + { + return false; + } + + return false; + } + public static string ToRelativePath(string rootFolder, string absolutePath) { if (string.IsNullOrWhiteSpace(rootFolder) || !Directory.Exists(rootFolder)) diff --git a/src/TrayApp/SettingsWindow.xaml b/src/TrayApp/SettingsWindow.xaml index 0bb57e3..995bf4b 100644 --- a/src/TrayApp/SettingsWindow.xaml +++ b/src/TrayApp/SettingsWindow.xaml @@ -6,18 +6,12 @@ Title="Local Build Monitor Settings" - Width="980" - - Height="820" - - MinWidth="900" - - MinHeight="760" - + Width="1200" + Height="800" + MinWidth="1020" + MinHeight="700" SizeToContent="Manual" - WindowStartupLocation="Manual" - Loaded="WindowLoaded"> @@ -112,420 +106,434 @@ - + + + + + + + + + + + + + + + + + - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -