Visitar URL original
fix(daemon): tests use their own Run key and settings file, and wait … · trgiangv/RevitDevTool@0d62a36 · GitHub
Skip to content

Commit 0d62a36

Browse files
committed
fix(daemon): tests use their own Run key and settings file, and wait for discovery to start
- StartupRegistration takes its HKCU key; Enable creates a missing key instead of doing nothing - UserSettingsStore takes its file path; DI passes the real Run key and settings.json - TempUserState gives each test its own key and file, so no test writes the user's autostart or settings - The discovery test waits for RunAsync before stopping; BackgroundService skips ExecuteAsync when stopped first
1 parent 67ee62e commit 0d62a36

11 files changed

Lines changed: 117 additions & 128 deletions

File tree

‎docs/agents/test-matrix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The `tests/DevTools.Mcp.*.Tests` names are from before the MCP merge. They test
1919
- Do not share state through `[DependsOn]`. MSTest only orders tests already in the run, so `--filter` on one test leaves its prerequisite out.
2020
- Test product code, not third-party tools: a test that only proves `pip install` or `dotnet` works belongs nowhere.
2121
- Turn on `[assembly: Parallelize(Scope = ExecutionScope.ClassLevel, Workers = 0)]` in a project only after five clean runs in a row (`DevTools.Daemon.Tests`, `DevTools.TestRunner.Tests`). Projects with timing-based waits (file watchers) or shared stores stay serial.
22+
- Tests never write machine or user state: the Daemon takes the HKCU key of its `StartupRegistration` and the path of `UserSettingsStore`, and `TempUserState` gives each test its own key and file.
2223
- UI tests find elements by AutomationId and act through UI Automation patterns (see HandyControl UI tests below).
2324
- Fixed product timings and outside services are passed in, with the production value as the default: `HostConnections` discovery interval, `PackageVersionChecker` `HttpClient`, `StdioHostedService` input stream, `SentryTelemetry` `ITransport`. Tests pass a short interval, canned JSON, `Stream.Null`, or a capturing transport.
2425
- Wait for an event or state, not for a timeout to expire: stop the run once the awaited status arrives. For an unreachable endpoint use a local server that closes at once (`RejectingServer` in `DevTools.Daemon.Tests`); a refused connect to `127.0.0.1:9` costs about 2 s on Windows. A window a test creates lives on its own thread with a message loop, as a real host UI thread does.

‎source/DevTools.Daemon/AppHostBuilder.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
using Microsoft.Extensions.Configuration;
2121
using Microsoft.Extensions.DependencyInjection;
2222
using Microsoft.Extensions.Hosting;
23+
using Microsoft.Extensions.Options;
2324

2425
namespace DevTools.Daemon;
2526

@@ -40,6 +41,7 @@ public static IHost CreateDesktop()
4041
builder.Services.AddSingleton<UpdateNotices>();
4142
builder.Services.AddSingleton<IUpdateNotifier>(sp => sp.GetRequiredService<UpdateNotices>());
4243
builder.Services.AddSingleton<UpdatesViewModel>();
44+
builder.Services.AddSingleton(StartupRegistration.CurrentUserRun);
4345
builder.Services.AddSingleton<ShellViewModel>();
4446
builder.Services.AddSingleton<MainWindow>();
4547
builder.Services.AddSingleton<TrayMenu>();
@@ -99,7 +101,7 @@ private static HostApplicationBuilder CreateBuilder(string[]? args = null)
99101
builder.Configuration.GetSection(GatewayOptions.SectionName));
100102
builder.Services.Configure<UserSettings>(
101103
builder.Configuration.GetSection(UserSettings.SectionName));
102-
builder.Services.AddSingleton<UserSettingsStore>();
104+
builder.Services.AddSingleton(sp => new UserSettingsStore(sp.GetRequiredService<IOptionsMonitor<UserSettings>>(), sp.GetRequiredService<IConfiguration>(), UserSettings.FilePath));
103105
builder.Services
104106
.AddFileMetadataReaders()
105107
.AddRevitFileMetadataReader()

‎source/DevTools.Daemon/Control/StartupRegistration.cs‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44

55
namespace DevTools.Daemon.Control;
66

7-
public static class StartupRegistration
7+
/// <summary>
8+
/// The <see cref="AppConstants.AutoStartValueName"/> value under an HKCU key; <see cref="CurrentUserRun"/> starts the Daemon with Windows.
9+
/// </summary>
10+
public sealed class StartupRegistration(string keyPath)
811
{
9-
private const string RunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
12+
public static StartupRegistration CurrentUserRun { get; } = new(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run");
1013

11-
public static bool IsEnabled
14+
public string KeyPath { get; } = keyPath;
15+
16+
public bool IsEnabled
1217
{
1318
get
1419
{
15-
using var key = Registry.CurrentUser.OpenSubKey(RunKey);
20+
using var key = Registry.CurrentUser.OpenSubKey(KeyPath);
1621
return key?.GetValue(AppConstants.AutoStartValueName) is not null;
1722
}
1823
}
@@ -36,28 +41,28 @@ public static string? DefaultExePath
3641
}
3742

3843
/// <summary>
39-
/// Starts the Daemon with Windows, in the background.
44+
/// Starts the Daemon in the background; creates the key when it is missing.
4045
/// </summary>
41-
public static void Enable(string? exePath = null)
46+
public void Enable(string? exePath = null)
4247
{
4348
exePath ??= DefaultExePath;
4449
if (exePath is null) return;
4550

46-
using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
47-
key?.SetValue(AppConstants.AutoStartValueName, $"\"{exePath}\" {IpcConstants.DaemonArgs.Background}");
51+
using var key = Registry.CurrentUser.CreateSubKey(KeyPath);
52+
key.SetValue(AppConstants.AutoStartValueName, $"\"{exePath}\" {IpcConstants.DaemonArgs.Background}");
4853
}
4954

5055
/// <summary>
51-
/// Writes the Run value again if it is on, so a value from an earlier version also gets the background argument.
56+
/// Writes the value again if it is on, so a value from an earlier version also gets the background argument.
5257
/// </summary>
53-
public static void Refresh()
58+
public void Refresh()
5459
{
5560
if (IsEnabled) Enable();
5661
}
5762

58-
public static void Disable()
63+
public void Disable()
5964
{
60-
using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true);
65+
using var key = Registry.CurrentUser.OpenSubKey(KeyPath, writable: true);
6166
key?.DeleteValue(AppConstants.AutoStartValueName, throwOnMissingValue: false);
6267
}
6368
}

‎source/DevTools.Daemon/Desktop/Models/UserSettingsStore.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
namespace DevTools.Daemon.Desktop.Models;
77

8-
public sealed class UserSettingsStore(IOptionsMonitor<UserSettings> monitor, IConfiguration configuration)
8+
public sealed class UserSettingsStore(IOptionsMonitor<UserSettings> monitor, IConfiguration configuration, string filePath)
99
{
1010
public UserSettings Current => monitor.CurrentValue;
1111

@@ -21,7 +21,7 @@ public void Update(Action<UserSettings> apply)
2121
try
2222
{
2323
File.WriteAllText(
24-
UserSettings.FilePath,
24+
filePath,
2525
JsonSerializer.Serialize(
2626
new Dictionary<string, UserSettings> { [UserSettings.SectionName] = next },
2727
UserSettingsJsonContext.Default.DictionaryStringUserSettings));

‎source/DevTools.Daemon/Desktop/ViewModels/SettingsViewModel.cs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ namespace DevTools.Daemon.Desktop.ViewModels;
1010
public partial class SettingsViewModel : ObservableObject
1111
{
1212
private readonly UserSettingsStore _settings;
13+
private readonly StartupRegistration _startup;
1314
private bool _suppressAutoStartSync;
1415

1516
[ObservableProperty]
@@ -20,9 +21,10 @@ public partial class SettingsViewModel : ObservableObject
2021

2122
public IReadOnlyList<AppTheme> Themes { get; } = Enum.GetValues<AppTheme>();
2223

23-
public SettingsViewModel(UserSettingsStore settings)
24+
public SettingsViewModel(UserSettingsStore settings, StartupRegistration startup)
2425
{
2526
_settings = settings;
27+
_startup = startup;
2628
Theme = settings.Current.Theme;
2729

2830
ReloadAutoStart();
@@ -33,7 +35,7 @@ public SettingsViewModel(UserSettingsStore settings)
3335
public void ReloadAutoStart()
3436
{
3537
_suppressAutoStartSync = true;
36-
AutoStartEnabled = StartupRegistration.IsEnabled;
38+
AutoStartEnabled = _startup.IsEnabled;
3739
_suppressAutoStartSync = false;
3840
}
3941

@@ -43,9 +45,9 @@ partial void OnAutoStartEnabledChanged(bool value)
4345
return;
4446

4547
if (value)
46-
StartupRegistration.Enable();
48+
_startup.Enable();
4749
else
48-
StartupRegistration.Disable();
50+
_startup.Disable();
4951

5052
_settings.Update(s => s.AutoStartEnabled = value);
5153
}

‎source/DevTools.Daemon/Desktop/ViewModels/ShellViewModel.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using CommunityToolkit.Mvvm.ComponentModel;
22
using DevTools.Daemon.Auth;
3+
using DevTools.Daemon.Control;
34
using DevTools.Daemon.Desktop.Models;
45
using DevTools.Daemon.Desktop.Updates;
56
using DevTools.Daemon.Mcp.Processes;
@@ -14,6 +15,7 @@ public partial class ShellViewModel(
1415
IMcpPipeScanner pipeScanner,
1516
UpdatesViewModel updater,
1617
UserSettingsStore settings,
18+
StartupRegistration startup,
1719
ITunnelStatusProvider tunnelStatus) : ObservableObject
1820
{
1921
public const int UpdatesTabIndex = 2;
@@ -23,7 +25,7 @@ public partial class ShellViewModel(
2325

2426
public OverviewViewModel Overview { get; } = new(authService, tunnelStatus);
2527
public HostsViewModel Hosts { get; } = new(sessions, pipeScanner);
26-
public SettingsViewModel Settings { get; } = new(settings);
28+
public SettingsViewModel Settings { get; } = new(settings, startup);
2729
public UpdatesViewModel Updater { get; } = updater;
2830
public string Version { get; } = AppVersion.Current;
2931

‎source/DevTools.Daemon/Program.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ public static int Main(string[] args)
2020
VelopackApp.Build()
2121
.SetArgs(args)
2222
.SetAutoApplyOnStartup(false)
23-
.OnAfterInstallFastCallback(_ => StartupRegistration.Enable())
23+
.OnAfterInstallFastCallback(_ => StartupRegistration.CurrentUserRun.Enable())
2424
.OnBeforeUninstallFastCallback(_ =>
2525
{
26-
StartupRegistration.Disable();
26+
StartupRegistration.CurrentUserRun.Disable();
2727
UpdateEngine.RemoveBundles(new AutodeskBundles(AutodeskBundles.DefaultRoot), new InstallationStore(InstallationStore.DefaultPath));
2828
})
2929
.Run();
3030

3131
if (VelopackLocator.IsCurrentSet && VelopackLocator.Current.CurrentlyInstalledVersion is not null)
32-
StartupRegistration.Refresh();
32+
StartupRegistration.CurrentUserRun.Refresh();
3333

3434
if (args.Contains(AppConstants.SelectArg, StringComparer.OrdinalIgnoreCase))
3535
return RunSelectAsync(args).GetAwaiter().GetResult();
@@ -77,7 +77,7 @@ private static async Task<int> RunSelectAsync(string[] args)
7777
var result = await engine.ApplySelectionAsync(selection, policy, Values(args, AppConstants.SourceArg).ToArray(), null, CancellationToken.None).ConfigureAwait(false);
7878

7979
Console.WriteLine($"RevitDevTool {result.Version}: installed [{string.Join(", ", result.Applied)}], pending [{string.Join(", ", result.Pending)}], unavailable [{string.Join(", ", result.Unavailable)}].");
80-
StartupRegistration.Enable();
80+
StartupRegistration.CurrentUserRun.Enable();
8181
return result.Unavailable.Count == 0 ? 0 : 3;
8282
}
8383

‎tests/DevTools.Daemon.Tests/DesktopComponentTests.cs‎

Lines changed: 31 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -73,71 +73,43 @@ public void SingleInstanceGuard_SecondStartAsksTheFirstToShow()
7373
[TestMethod]
7474
public void StartupRegistration_EnableDisable_RoundTripsRegistryValue()
7575
{
76-
var runKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
77-
object? original = null;
78-
using (var key = Registry.CurrentUser.OpenSubKey(runKey))
79-
original = key?.GetValue(AppConstants.AutoStartValueName);
76+
using var state = new TempUserState();
77+
var startup = state.Startup;
8078

81-
try
82-
{
83-
StartupRegistration.Disable();
84-
Assert.IsFalse(StartupRegistration.IsEnabled);
79+
startup.Disable();
80+
Assert.IsFalse(startup.IsEnabled);
8581

86-
StartupRegistration.Enable();
87-
if (Environment.ProcessPath is not null)
88-
{
89-
Assert.IsTrue(StartupRegistration.IsEnabled);
90-
using var key = Registry.CurrentUser.OpenSubKey(runKey);
91-
Assert.EndsWith($" {IpcConstants.DaemonArgs.Background}", (string)key!.GetValue(AppConstants.AutoStartValueName)!);
92-
}
82+
startup.Enable(@"C:\RevitDevTool\DevTools.Daemon.exe");
83+
Assert.IsTrue(startup.IsEnabled);
84+
using (var key = Registry.CurrentUser.OpenSubKey(startup.KeyPath))
85+
Assert.AreEqual($"\"C:\\RevitDevTool\\DevTools.Daemon.exe\" {IpcConstants.DaemonArgs.Background}", key!.GetValue(AppConstants.AutoStartValueName));
9386

94-
StartupRegistration.Disable();
95-
Assert.IsFalse(StartupRegistration.IsEnabled);
96-
}
97-
finally
98-
{
99-
using var key = Registry.CurrentUser.OpenSubKey(runKey, writable: true);
100-
if (original is null)
101-
key?.DeleteValue(AppConstants.AutoStartValueName, throwOnMissingValue: false);
102-
else
103-
key?.SetValue(AppConstants.AutoStartValueName, original);
104-
}
87+
startup.Disable();
88+
Assert.IsFalse(startup.IsEnabled);
10589
}
10690

10791
[TestMethod]
10892
public void UserSettingsStore_Update_WritesAndReloadsSettings()
10993
{
110-
var settingsPath = UserSettings.FilePath;
111-
var backup = File.Exists(settingsPath) ? File.ReadAllText(settingsPath) : null;
112-
113-
try
94+
using var state = new TempUserState();
95+
var store = DaemonTestDoubles.CreateUserSettingsStore(state.SettingsPath, new UserSettings
11496
{
115-
var store = DaemonTestDoubles.CreateUserSettingsStore(new UserSettings
116-
{
117-
Theme = AppTheme.Light,
118-
AutoStartEnabled = false,
119-
});
120-
121-
store.Update(s =>
122-
{
123-
s.Theme = AppTheme.Dark;
124-
s.AutoStartEnabled = true;
125-
});
97+
Theme = AppTheme.Light,
98+
AutoStartEnabled = false,
99+
});
126100

127-
var loaded = JsonSerializer.Deserialize(
128-
File.ReadAllText(settingsPath),
129-
UserSettingsJsonContext.Default.DictionaryStringUserSettings);
130-
Assert.AreEqual(AppTheme.Dark, loaded![UserSettings.SectionName].Theme);
131-
Assert.IsTrue(loaded[UserSettings.SectionName].AutoStartEnabled);
132-
Assert.Contains("\"Theme\"", File.ReadAllText(settingsPath), StringComparison.Ordinal);
133-
}
134-
finally
101+
store.Update(s =>
135102
{
136-
if (backup is null)
137-
File.Delete(settingsPath);
138-
else
139-
File.WriteAllText(settingsPath, backup);
140-
}
103+
s.Theme = AppTheme.Dark;
104+
s.AutoStartEnabled = true;
105+
});
106+
107+
var loaded = JsonSerializer.Deserialize(
108+
File.ReadAllText(state.SettingsPath),
109+
UserSettingsJsonContext.Default.DictionaryStringUserSettings);
110+
Assert.AreEqual(AppTheme.Dark, loaded![UserSettings.SectionName].Theme);
111+
Assert.IsTrue(loaded[UserSettings.SectionName].AutoStartEnabled);
112+
Assert.Contains("\"Theme\"", File.ReadAllText(state.SettingsPath), StringComparison.Ordinal);
141113
}
142114

143115
[TestMethod]
@@ -181,21 +153,20 @@ public void FileLogging_ConfiguresRollingFileProvider()
181153
[TestMethod]
182154
public async Task DiscoveryHostedService_StartsAndStops()
183155
{
184-
var running = CancellationToken.None;
156+
var running = new TaskCompletionSource<CancellationToken>(TaskCreationOptions.RunContinuationsAsynchronously);
185157
var discovery = new Mock<IHostConnections>();
186158
discovery.Setup(d => d.RunAsync(It.IsAny<CancellationToken>()))
187159
.Returns<CancellationToken>(ct =>
188160
{
189-
running = ct;
161+
running.SetResult(ct);
190162
return Task.Delay(Timeout.Infinite, ct);
191163
});
192164

193165
var service = new DiscoveryHostedService(discovery.Object);
194-
using var cts = new CancellationTokenSource();
195-
await service.StartAsync(cts.Token);
196-
await cts.CancelAsync();
166+
await service.StartAsync(CancellationToken.None);
167+
var token = await running.Task.WaitAsync(TimeSpan.FromSeconds(10));
197168
await service.StopAsync(CancellationToken.None);
198169

199-
Assert.IsTrue(running.IsCancellationRequested);
170+
Assert.IsTrue(token.IsCancellationRequested);
200171
}
201172
}

‎tests/DevTools.Daemon.Tests/Support/DaemonTestDoubles.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace DevTools.Daemon.Tests.Support;
2020
internal static class DaemonTestDoubles
2121
{
2222
public static UserSettingsStore CreateUserSettingsStore(
23+
string filePath,
2324
UserSettings? settings = null,
2425
Action<ConfigurationBuilder>? configure = null)
2526
{
@@ -35,7 +36,7 @@ public static UserSettingsStore CreateUserSettingsStore(
3536

3637
var monitor = new Mock<IOptionsMonitor<UserSettings>>();
3738
monitor.Setup(m => m.CurrentValue).Returns(settings);
38-
return new UserSettingsStore(monitor.Object, configuration);
39+
return new UserSettingsStore(monitor.Object, configuration, filePath);
3940
}
4041

4142
public static Mock<IAuthService> CreateAuthService(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using DevTools.Daemon.Control;
2+
using Microsoft.Win32;
3+
4+
namespace DevTools.Daemon.Tests.Support;
5+
6+
/// <summary>
7+
/// A settings file and an HKCU key of one test, so no test touches the real <c>settings.json</c> or the Windows <c>Run</c> key.
8+
/// </summary>
9+
internal sealed class TempUserState : IDisposable
10+
{
11+
private readonly string _id = Guid.NewGuid().ToString("N");
12+
13+
public TempUserState()
14+
{
15+
Startup = new StartupRegistration($@"Software\RevitDevTool.Tests.{_id}");
16+
SettingsPath = Path.Combine(Path.GetTempPath(), $"RevitDevTool.Tests.{_id}.json");
17+
}
18+
19+
public StartupRegistration Startup { get; }
20+
21+
public string SettingsPath { get; }
22+
23+
public void Dispose()
24+
{
25+
Registry.CurrentUser.DeleteSubKeyTree(Startup.KeyPath, throwOnMissingSubKey: false);
26+
File.Delete(SettingsPath);
27+
}
28+
}

0 commit comments

Comments
 (0)