// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.Threading.Channels;
namespace Aspire.Tray;
/// <summary>
/// Exercises real Win32 resources and dispatch with isolated, non-executable AppHost fixtures.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class NativeSmokeHarness
{
private readonly SmokeClient _client = new();
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aspire-tray-smoke-");
private readonly MemoryTraySavedStateStore _store = new();
private readonly SmokeStartupSettings _startupSettings = new();
private readonly bool _interactiveSmoke;
private readonly CancellationTokenSource _shutdown = new();
private readonly AppHostInfo[] _hosts;
private readonly string _pinned;
private readonly string _recent;
private readonly string _missing;
private readonly string _missingPin;
private TrayController _controller = null!;
private TrayApplication _application = null!;
private int _phase;
private int _lastLoggedPhase = -1;
private int _dashboardCalls;
private int _documentationCalls;
private int _confirmations;
private int _errors;
private readonly List<string> _copiedPaths = [];
private bool _accept;
private bool _finished;
private bool _replaceDuringConfirmation;
private bool _tooltipSelectionRequested;
private bool _tooltipHideRequested;
private int _retainedStop;
private nint _trackingMenu;
private nint _oldIcon;
private IReadOnlyList<AppHostId> _trackingRows = [];
private Task? _restore;
private Task? _quit;
private NativeSmokeHarness(bool interactiveSmoke)
{
_interactiveSmoke = interactiveSmoke || Environment.GetEnvironmentVariable("ASPIRE_TRAY_SMOKE_INTERACTIVE") == "1";
_hosts = Enum.GetValues<AppHostHealth>().Select((health, index) => new AppHostInfo(
CreateFile(index == 0 ? "Host0 & caf\u00e9.AppHost.cs" : $"Host{index}.AppHost.cs"),
41001 + index, index == 0 ? "http://localhost:19001/" : null)
{
ProcessStartTimeUnixMilliseconds = 1_700_000_000_001 + index, Health = health
}).ToArray();
_pinned = CreateFile("Pinned.AppHost.cs");
_recent = CreateFile("Recent & caf\u00e9.AppHost.cs");
_missing = Path.Combine(_directory.FullName, "Missing.AppHost.cs");
_missingPin = CreateFile("MissingPin.AppHost.cs");
_store.Save(new TraySavedState([
new(_pinned, true, true), new(_recent, false, true), new(_missing, false, true), new(_missingPin, true, false)
]));
}
internal static int Run(string cliPath, int seconds, bool interactiveSmoke)
{
_ = cliPath; // Smoke identities are never passed to a real CLI client.
var harness = new NativeSmokeHarness(interactiveSmoke);
return harness.RunCore(seconds);
}
private string CreateFile(string name)
{
var path = Path.Combine(_directory.FullName, name);
File.WriteAllText(path, "// Native smoke fixture; never executed.");
return path;
}
private int RunCore(int seconds)
{
_controller = new(_client, _store);
_application = new(_controller, seconds, _startupSettings)
{
IconAddFailuresForSmoke = 3,
SmokeTick = Advance,
OpenUrlForSmoke = uri =>
{
if (uri.AbsoluteUri == "https://aspire.dev/")
{
_documentationCalls++;
}
else
{
Require(uri.AbsoluteUri == "http://localhost:19001/", "Unexpected dashboard URL.");
_dashboardCalls++;
}
},
CopyPathForSmoke = path => _copiedPaths.Add(path),
ConfirmForSmoke = (title, detail, flags) =>
{
Require(flags == NativeMethods.SafeConfirmation && (flags & 0x100) != 0, "Confirmation must default to Cancel.");
Require(!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(detail), "Confirmation has no context.");
if (title == "Stop AppHost")
{
Require(!detail.Contains(_directory.FullName, StringComparison.Ordinal)
&& detail.Contains("PID", StringComparison.Ordinal)
&& detail.Contains("running resources", StringComparison.Ordinal),
"Stop confirmation must explain its consequence without the AppHost path.");
}
_confirmations++;
if (_replaceDuringConfirmation)
{
_replaceDuringConfirmation = false;
Publish([Replacement(_hosts[0]), Replacement(_hosts[1]), _hosts[2], _hosts[3]]);
_application.DialogReadyForSmoke = () => _controller.State.AppHosts.Any(host => host.Id == Replacement(_hosts[1]).Id);
}
return _accept;
},
ErrorForSmoke = error =>
{
Require(error is InvalidOperationException, "Unexpected action failure in smoke.");
_errors++;
}
};
try
{
var folder = Directory.CreateDirectory(Path.Combine(_directory.FullName, "project;new-tab")).FullName;
var terminal = new FolderApplication("Windows Terminal",
Environment.ProcessPath ?? throw new InvalidOperationException("The smoke executable path is unavailable."), true);
var launch = terminal.CreateFolderStartInfo(folder);
Require(launch.WorkingDirectory == folder && launch.ArgumentList.SequenceEqual(["-d", "."]),
"Terminal folder launch must keep semicolons out of its command grammar.");
Require(!_application.WaitUntilReadyAsync(_shutdown.Token).IsCompleted, "Readiness completed without a running native loop.");
_restore = Task.Run(() => _application.RestoreIconAsync(_shutdown.Token));
_controller.Start();
_application.Run();
Require(_finished, "Smoke exited before completing its assertions.");
}
finally
{
_shutdown.Cancel();
try
{
_controller.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
finally
{
_application.Dispose();
_shutdown.Dispose();
_directory.Delete(recursive: true);
}
}
return _application.ExitCode;
}
private void Advance()
{
if (_phase != _lastLoggedPhase)
{
Program.Log($"Windows native smoke phase {_phase}.");
_lastLoggedPhase = _phase;
}
var state = _controller.State;
Require(_application.WaitUntilReadyAsync(_shutdown.Token).IsCompletedSuccessfully, "Native loop readiness was not acknowledged.");
switch (_phase)
{
case 0:
if (_restore?.IsCompleted != true)
{
return;
}
_restore.GetAwaiter().GetResult();
Require(_application.IconAddFailuresForSmoke == 0, "Readiness was acknowledged before icon creation recovered.");
_application.VerifyNativeStateForSmoke();
Require(state.Discovery == DiscoveryState.Connecting && !state.HasActiveAppHosts, "Initial native state is incorrect.");
VerifySettings();
_phase = 1;
Publish(_hosts);
break;
case 1:
if (state.Discovery != DiscoveryState.Live)
{
return;
}
_application.VerifyNativeStateForSmoke();
Require(_client.Starts.IsEmpty, "A recent submenu started an AppHost automatically.");
var paths = state.AppHosts.Concat(state.RecentAppHosts).Select(host => host.Id).ToArray();
foreach (var id in paths)
{
Invoke("CopyPath", id);
}
Require(_copiedPaths.SequenceEqual(_hosts.Select(host => host.AppHostPath)
.Concat([_pinned, _missingPin, _recent, _missing]).Select(Path.GetDirectoryName)),
"Copy Path must copy the exact containing folder for live, pinned, and missing recent AppHosts.");
Invoke("Dashboard", _hosts[0].Id);
Require(_dashboardCalls == 1, "Native dashboard dispatch did not reach the URL handler.");
Invoke("Documentation");
Require(_documentationCalls == 1, "Documentation did not use aspire.dev.");
_application.ShowMessageForSmoke();
_retainedStop = _application.CaptureActionForSmoke("Stop", _hosts[0].Id);
_application.SuppressStopForSmoke = true;
_application.InvokeActionForSmoke(_retainedStop);
Require(_confirmations == 1, "The stop confirmation was not shown exactly once.");
Require(_client.Stops.IsEmpty, "Cancel dispatched a stop operation.");
Require(_controller.ConfirmStop && _store.Load().ConfirmStop, "Cancel persisted the suppression checkbox.");
_application.SuppressStopForSmoke = false;
Invoke("TogglePin", _hosts[0].Id);
Require(_controller.State.AppHosts.Single(host => host.Id == _hosts[0].Id).IsPinned, "Pin action did not persist.");
_accept = true;
_application.InvokeActionForSmoke(_retainedStop);
_phase = 2;
break;
case 2:
if (_client.Stops.Count != 1 || !state.AppHosts.Single(host => host.Id == _hosts[0].Id).IsStopping)
{
return;
}
_application.VerifyNativeStateForSmoke();
_phase = 3;
_application.TrackForSmoke(_hosts[0].Id);
Require(_phase == 5, "Native menu tracking ended before retained actions were verified.");
_application.VerifyNoTooltipForSmoke();
break;
case 3:
Require(_application.IsTrackingForSmoke, "The actual TrackPopupMenuEx loop is not active.");
if (!_tooltipSelectionRequested)
{
_application.SelectMenuDetailsForSmoke(_hosts[0].Id, details: true);
_tooltipSelectionRequested = true;
return;
}
if (!_application.IsTooltipVisibleForSmoke)
{
return;
}
_application.VerifyMenuDetailsForSmoke(_hosts[0].Id,
$"{_hosts[0].AppHostPath} \u00b7 Stopping AppHost...");
_application.VerifyDetailsTooltipForSmoke(_hosts[0].Id,
$"{_hosts[0].AppHostPath} \u00b7 Stopping AppHost...");
_trackingMenu = _application.MenuForSmoke;
_trackingRows = _application.RowIdsForSmoke;
_phase = 4;
Publish([_hosts[3], _hosts[2], _hosts[1], Replacement(_hosts[0])]);
break;
case 4:
if (!state.AppHosts.Any(host => host.Id == Replacement(_hosts[0]).Id))
{
return;
}
Require(_application.MenuForSmoke == _trackingMenu && _application.RowIdsForSmoke.SequenceEqual(_trackingRows),
"A tracked HMENU or row identity was replaced.");
_application.VerifyNativeStateForSmoke(retained: true);
_application.VerifyMenuDetailsForSmoke(_hosts[0].Id,
$"{_hosts[0].AppHostPath} \u00b7 AppHost no longer available");
if (!_tooltipHideRequested)
{
_application.VerifyDetailsTooltipForSmoke(_hosts[0].Id,
$"{_hosts[0].AppHostPath} \u00b7 AppHost no longer available");
_application.SelectMenuDetailsForSmoke(_hosts[0].Id, details: false);
_tooltipHideRequested = true;
return;
}
_application.VerifyNoTooltipForSmoke();
_application.InvokeActionForSmoke(_retainedStop);
Require(_errors == 1 && _confirmations == 2 && _client.Stops.Count == 1, "A stale sender stopped or confirmed a replacement lifetime.");
_application.SelectMenuDetailsForSmoke(_hosts[0].Id, details: true);
_phase = 5;
_application.EndTrackingForSmoke();
break;
case 5:
_application.VerifyNativeStateForSmoke();
_replaceDuringConfirmation = true;
_application.SuppressStopForSmoke = true;
Invoke("Stop", _hosts[1].Id);
Require(_errors == 2 && _client.Stops.Count == 1, "A lifetime replaced during a native confirmation was stopped.");
Require(_controller.ConfirmStop && _store.Load().ConfirmStop, "A stale confirmation changed the saved preference.");
Invoke("Stop", _hosts[2].Id);
Require(!_controller.ConfirmStop && !_store.Load().ConfirmStop, "Accepted Stop did not persist the suppression checkbox.");
var missing = state.RecentAppHosts.Single(host => TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, _missing));
_accept = false;
Invoke("Start", missing.Id);
Require(_controller.State.RecentAppHosts.Any(host => host.Id == missing.Id), "Cancel removed a missing recent path.");
_accept = true;
Invoke("Start", missing.Id);
Require(_controller.State.RecentAppHosts.All(host => host.Id != missing.Id), "Missing recent path was not removed.");
var pinned = state.AppHosts.Single(host => TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, _pinned));
Invoke("Start", pinned.Id);
_phase = 6;
break;
case 6:
if (_client.Starts.Count != 1 || _client.Stops.Count != 2)
{
return;
}
Require(_client.Starts.Single() == _pinned, "Explicit Start did not preserve the selected path and casing.");
_application.VerifyNativeStateForSmoke();
var saved = _store.Load();
_accept = false;
Invoke("ClearRecent");
Require(ReferenceEquals(saved, _store.Load()), "Cancel changed recent history.");
_accept = true;
Invoke("ClearRecent");
Require(_store.Load().AppHosts.All(host => host.IsPinned && !host.IsRecent)
&& _store.Load().AppHosts.Any(host => TrayAppHostPath.Comparer.Equals(host.AppHostPath, _pinned)), "Clear Recent did not preserve pins.");
File.Delete(_missingPin);
_phase = 7;
break;
case 7:
if (state.AppHosts.Any(host => TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, _missingPin)))
{
return;
}
Require(_store.Load().AppHosts.All(host => !TrayAppHostPath.Comparer.Equals(host.AppHostPath, _missingPin)),
"Missing offline pin was not persistently pruned.");
var confirmations = _confirmations;
Invoke("Stop", _hosts[3].Id);
Require(_confirmations == confirmations, "Stop prompted despite the saved opt-out.");
_application.RestartExplorerForSmoke();
_oldIcon = _application.IconForSmoke;
_application.InvalidateArtworkForSmoke();
_phase = 8;
break;
case 8:
if (_application.IconForSmoke == _oldIcon)
{
return;
}
_application.VerifyNativeStateForSmoke();
_restore = Task.Run(() => _application.RestoreIconAsync(_shutdown.Token));
_phase = 9;
break;
case 9:
if (_restore?.IsCompleted != true)
{
return;
}
_restore.GetAwaiter().GetResult();
_client.CompleteStart();
// A successful start remains pending until discovery sees its instance.
Publish([new(_pinned, 42001, "http://localhost:19001/") { ProcessStartTimeUnixMilliseconds = 1_700_000_000_100 }]);
_phase = 10;
break;
case 10:
if (!state.AppHosts.Any(host => host.IsRunning && TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, _pinned)))
{
return;
}
_application.VerifyNativeStateForSmoke();
Invoke("Dashboard", state.AppHosts.Single(host => host.IsRunning).Id);
Require(_dashboardCalls == 2, "A started AppHost did not gain an actionable dashboard menu entry.");
// Disconnection preserves the last live rows. Observe an empty live
// snapshot before checking the disconnected, inactive presentation.
Publish([]);
_phase = 11;
break;
case 11:
if (state.HasActiveAppHosts)
{
return;
}
Publish([], DiscoveryState.Disconnected);
_phase = 12;
break;
case 12:
if (state.Discovery != DiscoveryState.Disconnected || state.HasActiveAppHosts)
{
return;
}
_application.VerifyNativeStateForSmoke();
Require(state.AppHosts.All(host => !host.CanStop && !host.CanStart), "Disconnected rows allow lifecycle actions.");
_finished = true;
_phase = 13;
Program.Log("Windows native smoke passed: AppHost health icons, Documentation/Settings command icons, running AppHost dashboard actions, divided actions, separate status/path details, full-value details tooltips, live/stale refresh, tooltip focus/dismissal, compact names without status suffixes, menu tracking, immutable actions, native stop checkbox/opt-out, original-case Copy path, pins/history, discovery icons, artwork invalidation, Explorer recovery, activation, terminal arguments, compact modeless Settings with About logo and isolated startup preferences.");
Program.Log("Not covered by synthetic smoke: real per-monitor DPI transitions; verify these on the Windows desktop.");
if (_interactiveSmoke)
{
// Keep the isolated fake client/store; never connect preview gestures
// to a real CLI or the account's startup registration.
_controller.SetConfirmStop(true);
Publish(_hosts.Select(host => host with
{
ProcessStartTimeUnixMilliseconds = host.ProcessStartTimeUnixMilliseconds + 1000
}).ToArray());
_application.BeginInteractivePreviewForSmoke();
Program.Log("Windows native preview ready: fake AppHosts and in-memory preferences only. Quit Aspire to exit.");
}
else
{
_quit = Task.Run(_application.RequestQuit);
}
break;
case 13:
_quit?.GetAwaiter().GetResult();
break;
}
}
private void Invoke(string kind, AppHostId id = default)
=> _application.InvokeActionForSmoke(_application.CaptureActionForSmoke(kind, id));
private void VerifySettings()
{
const string off = "Launch at sign-in is off.";
const string on = "Launch at sign-in is on.";
Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 0, "Smoke startup preferences must default off.");
Invoke("Settings");
_application.VerifySettingsForSmoke(0, true, "");
var firstWindow = _application.SettingsWindowForSmoke;
Invoke("Settings");
Require(_application.SettingsWindowForSmoke == firstWindow && _startupSettings.WriteCount == 0,
"Opening Settings created a duplicate window or registered startup.");
_application.ClickSettingsControlForSmoke("Startup");
_application.VerifySettingsForSmoke(1, true, "");
Require(_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 1, "The native checkbox did not enable the isolated setting.");
_application.CloseSettingsForSmoke();
Invoke("Settings");
_application.VerifySettingsForSmoke(1, true, "");
Require(_startupSettings.WriteCount == 1, "Reopening Settings changed startup registration.");
_application.ClickSettingsControlForSmoke("Startup");
_application.VerifySettingsForSmoke(0, true, "");
Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 2, "The native checkbox did not disable the setting.");
_startupSettings.CanEnable = false;
_application.RefreshSettingsForSmoke();
_application.VerifySettingsForSmoke(0, false,
"Launch at sign-in requires a stable native CLI installation.");
_application.ClickSettingsControlForSmoke("Startup");
Require(_startupSettings.WriteCount == 2, "An unavailable startup setting was enabled.");
_startupSettings.Store.SetEnabled(true);
_application.RefreshSettingsForSmoke();
_application.VerifySettingsForSmoke(1, true,
"Launch at sign-in requires a stable native CLI installation.");
_application.ClickSettingsControlForSmoke("Startup");
Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 3,
"An existing registration could not be disabled when enabling was unavailable.");
_startupSettings.CanEnable = true;
_startupSettings.FailWrite = true;
_application.RefreshSettingsForSmoke();
_application.ClickSettingsControlForSmoke("Startup");
_application.VerifySettingsForSmoke(0, true,
$"Could not change launch at sign-in: Simulated startup write failure.\r\n{off}");
Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 4,
"A failed write produced a phantom enabled setting.");
_startupSettings.FailWrite = false;
_startupSettings.FailAfterWrite = true;
_application.ClickSettingsControlForSmoke("Startup");
_application.VerifySettingsForSmoke(1, true,
$"Could not change launch at sign-in: Simulated partial startup write failure.\r\n{on}");
Require(_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 5,
"A partially completed write was not re-read from the backing store.");
_startupSettings.FailAfterWrite = false;
_application.ClickSettingsControlForSmoke("Startup");
_application.VerifySettingsForSmoke(0, true, "");
_startupSettings.FailRead = true;
_application.RefreshSettingsForSmoke();
const string unknown = "Could not read launch at sign-in: Simulated startup read failure.\r\nClose and reopen Settings to retry.";
_application.VerifySettingsForSmoke(2, false, unknown);
_application.CloseSettingsForSmoke();
Invoke("Settings");
_application.VerifySettingsForSmoke(2, false, unknown);
Require(_startupSettings.WriteCount == 6, "Opening an unknown startup state attempted registration.");
_startupSettings.FailRead = false;
_startupSettings.FailWrite = false;
_application.RefreshSettingsForSmoke();
_application.VerifySettingsForSmoke(0, true, "");
_startupSettings.CanEnable = false;
_startupSettings.Detail = string.Join(" ", Enumerable.Repeat(
"Launch at sign-in requires a verified stable native CLI installation; development builds cannot register startup.", 8));
_application.RefreshSettingsForSmoke();
_application.VerifySettingsForSmoke(0, false,
"Launch at sign-in requires a stable native CLI installation.");
_startupSettings.Detail = null;
_startupSettings.CanEnable = true;
_application.RefreshSettingsForSmoke();
_application.VerifySettingsForSmoke(0, true, "");
_application.VerifySettingsScrollingForSmoke();
Require(_documentationCalls == 0, "Settings unexpectedly opened documentation.");
_application.CloseSettingsForSmoke();
Require(_startupSettings.WriteCount == 6 && !_startupSettings.Store.Read().Enabled,
"Closing Settings changed startup state.");
}
private void Publish(IReadOnlyList<AppHostInfo> hosts, DiscoveryState discovery = DiscoveryState.Live)
=> _client.Publish(new(hosts, discovery));
private static AppHostInfo Replacement(AppHostInfo host)
=> host with { ProcessStartTimeUnixMilliseconds = host.ProcessStartTimeUnixMilliseconds + 100 };
internal static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
// Fault injection wraps only MemoryTrayStartupSettings. This harness cannot register
// startup with the OS, including when simulating a registration that already exists.
private sealed class SmokeStartupSettings : ITrayStartupSettings
{
internal MemoryTrayStartupSettings Store { get; } = new();
internal bool CanEnable { get; set; } = true;
internal bool FailRead { get; set; }
internal bool FailWrite { get; set; }
internal bool FailAfterWrite { get; set; }
internal string? Detail { get; set; }
internal int WriteCount { get; private set; }
public TrayStartupState Read()
{
if (FailRead)
{
throw new IOException("Simulated startup read failure.");
}
var state = Store.Read();
return state with { CanEnable = CanEnable, Detail = Detail ?? state.Detail };
}
public TrayStartupState SetEnabled(bool enabled)
{
WriteCount++;
if (FailWrite)
{
throw new IOException("Simulated startup write failure.");
}
Require(!enabled || CanEnable, "Startup enabling is unavailable.");
var state = Store.SetEnabled(enabled) with { CanEnable = CanEnable };
if (FailAfterWrite)
{
throw new IOException("Simulated partial startup write failure.");
}
return state;
}
}
private sealed class SmokeClient : IAppHostClient
{
private readonly Channel<AppHostSnapshot> _snapshots = Channel.CreateUnbounded<AppHostSnapshot>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
internal ConcurrentBag<AppHostId> Stops { get; } = [];
internal ConcurrentBag<string> Starts { get; } = [];
private readonly TaskCompletionSource<StartResult> _start = new(TaskCreationOptions.RunContinuationsAsynchronously);
internal void CompleteStart() => _start.SetResult(new(StartOutcome.Started, 0));
internal void Publish(AppHostSnapshot snapshot)
=> Require(_snapshots.Writer.TryWrite(snapshot), "The smoke snapshot channel is closed.");
public async IAsyncEnumerable<AppHostSnapshot> WatchAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var snapshot in _snapshots.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return snapshot;
}
}
public async Task<StopResult> StopAsync(AppHostId id, CancellationToken cancellationToken)
{
Stops.Add(id);
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException("The fake stop must be cancelled by smoke cleanup.");
}
public async Task<StartResult> StartAsync(string appHostPath, CancellationToken cancellationToken)
{
Starts.Add(appHostPath);
return await _start.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
}
}