Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ VSTest can print `Test run for <dll>` **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.
Expand Down
115 changes: 115 additions & 0 deletions src/BuildMonitor.Tests/SettingsBuildCliContextPresenterTests.cs
Original file line number Diff line number Diff line change
@@ -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 }
}
};
}
174 changes: 174 additions & 0 deletions src/BuildMonitor.Tests/SettingsProjectCapabilityPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -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<AppSettings>(
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, "<Project />");
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);
}
}
67 changes: 67 additions & 0 deletions src/BuildMonitor.Tests/ThemeComboBoxStyleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Text.RegularExpressions;

namespace BuildMonitor.Tests;

/// <summary>
/// Guards the #89 regression where keyed Settings ComboBox styles omitted BasedOn
/// and dropped dark-theme chrome (light system background + light foreground).
/// </summary>
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,
"""<Style\s+TargetType="\{x:Type ComboBox\}"[^>]*>.*?</Style>""",
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));
}
}
Loading
Loading