File: NativeSmokeHarness.cs
Web Access
Project: src\src\Aspire.Tray\Windows\Aspire.Tray.Windows.csproj (aspire-tray)
// 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 _errors;
    private readonly List<string> _copiedPaths = [];
    private bool _finished;
    private AppHostMenuItem? _staleHost;
    private nint _oldIcon;
    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),
            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.");
                _application.VerifyActivationForSmoke();
                VerifySettings();
                _phase = 1;
                Publish(_hosts);
                break;
            case 1:
                if (state.Discovery != DiscoveryState.Live)
                {
                    return;
                }
                _application.VerifyNativeStateForSmoke();
                Require(_client.Starts.IsEmpty, "Listing a recent AppHost started it automatically.");
                foreach (var id in state.AppHosts.Concat(state.RecentAppHosts).Select(host => host.Id))
                {
                    _application.PickRowActionForSmoke(id, PanelTargetKind.CopyPath);
                }
                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.");
                _application.ClickPanelForSmoke(PanelTargetKind.Dashboard, _hosts[0].Id);
                Require(_dashboardCalls == 1, "The panel's Dashboard button did not reach the URL handler.");
                _application.ClickPanelForSmoke(PanelTargetKind.Documentation);
                Require(_documentationCalls == 1, "Documentation did not use aspire.dev.");
                _application.ShowMessageForSmoke();
                _application.VerifyActionListForSmoke(_hosts[1].Id);
                _application.PickRowActionForSmoke(_hosts[0].Id, PanelTargetKind.TogglePin);
                Require(_controller.State.AppHosts.Single(host => host.Id == _hosts[0].Id).IsPinned, "Pin from the action list did not persist.");
                _staleHost = _controller.State.AppHosts.Single(host => host.Id == _hosts[0].Id);
                _application.ClickPanelForSmoke(PanelTargetKind.Stop, _hosts[0].Id);
                _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;
                Publish([_hosts[3], _hosts[2], _hosts[1], Replacement(_hosts[0])]);
                break;
            case 3:
                if (!state.AppHosts.Any(host => host.Id == Replacement(_hosts[0]).Id))
                {
                    return;
                }
                _application.VerifyNativeStateForSmoke();
                // An action from a scene drawn before the lifetime was replaced must be rejected.
                _application.RunPanelActionForSmoke(new(PanelActionKind.Stop, _staleHost));
                Require(_errors == 1 && _client.Stops.Count == 1, "A stale action stopped a replacement lifetime.");
                // Stopping isn't confirmed: the request goes out at once, here through UI Automation.
                _application.PressPanelForSmoke(PanelTargetKind.Stop, _hosts[2].Id);
                Require(_client.Stops.Count == 2, "Stop did not send its request without a dialog.");
                var missing = state.RecentAppHosts.Single(host => TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, _missing));
                _application.ClickPanelForSmoke(PanelTargetKind.Start, missing.Id);
                Require(_application.PanelForSmoke.Confirmation == PanelConfirmKind.RemoveMissing, "A missing recent AppHost was not offered for removal inline.");
                _application.ClickPanelForSmoke(PanelTargetKind.ConfirmCancel, missing.Id);
                Require(_controller.State.RecentAppHosts.Any(host => host.Id == missing.Id), "Cancel removed a missing recent path.");
                _application.ClickPanelForSmoke(PanelTargetKind.Start, missing.Id);
                _application.ClickPanelForSmoke(PanelTargetKind.ConfirmAccept, 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));
                _application.ClickPanelForSmoke(PanelTargetKind.Start, pinned.Id);
                _phase = 4;
                break;
            case 4:
                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();
                _application.ClickPanelForSmoke(PanelTargetKind.ClearRecent);
                Require(_application.PanelForSmoke.Confirmation == PanelConfirmKind.ClearRecent, "Clear did not ask inline.");
                _application.ClickPanelForSmoke(PanelTargetKind.ConfirmCancel);
                Require(ReferenceEquals(saved, _store.Load()), "Cancel changed recent history.");
                _application.ClickPanelForSmoke(PanelTargetKind.ClearRecent);
                _application.ClickPanelForSmoke(PanelTargetKind.ConfirmAccept);
                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);
                _application.ReopenPanelForSmoke();
                _phase = 5;
                break;
            case 5:
                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.");
                _application.RestartExplorerForSmoke();
                _oldIcon = _application.IconForSmoke;
                _application.InvalidateArtworkForSmoke();
                _phase = 6;
                break;
            case 6:
                if (_application.IconForSmoke == _oldIcon)
                {
                    return;
                }
                _application.VerifyNativeStateForSmoke();
                _restore = Task.Run(() => _application.RestoreIconAsync(_shutdown.Token));
                _phase = 7;
                break;
            case 7:
                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 = 8;
                break;
            case 8:
                if (!state.AppHosts.Any(host => host.IsRunning && TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, _pinned)))
                {
                    return;
                }
                _application.VerifyNativeStateForSmoke();
                _application.ClickPanelForSmoke(PanelTargetKind.Dashboard, state.AppHosts.Single(host => host.IsRunning).Id);
                Require(_dashboardCalls == 2, "A started AppHost did not gain an actionable dashboard button.");
                // Disconnection preserves the last live rows. Observe an empty live
                // snapshot before checking the disconnected, inactive presentation.
                Publish([]);
                _phase = 9;
                break;
            case 9:
                if (state.HasActiveAppHosts)
                {
                    return;
                }
                Publish([], DiscoveryState.Disconnected);
                _phase = 10;
                break;
            case 10:
                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 = 11;
                Program.Log("Windows native smoke passed: every icon activation opens the flyout (left, right, keyboard with focus) and repeats keep it open, Ctrl+comma and the Settings view with isolated startup preferences, panel rows matching the view state, Dashboard and Documentation buttons, action list items with UI Automation and keyboard, original-case Copy path for live/pinned/recent rows, pin from the action list, unconfirmed Stop by click and UI Automation with stale-lifetime safety, inline missing-file removal and Clear, pin pruning, discovery icons, artwork invalidation, Explorer recovery, activation, terminal arguments.");
                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.
                    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. Exit in the panel to close it.");
                }
                else
                {
                    _quit = Task.Run(_application.RequestQuit);
                }
                break;
            case 11:
                _quit?.GetAwaiter().GetResult();
                break;
        }
    }
 
    /// <summary>
    /// Drives the panel's Settings view against the isolated startup settings, including the
    /// unavailable, failed-write, partial-write and failed-read states.
    /// </summary>
    private void VerifySettings()
    {
        Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 0, "Smoke startup preferences must default off.");
        _application.VerifySettingsViewForSmoke(false, true, TraySettingsText.StartupDescription);
        _application.ToggleLaunchAtSignInForSmoke();
        _application.VerifySettingsViewForSmoke(true, true, TraySettingsText.StartupDescription);
        Require(_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 1, "The switch did not enable the isolated setting.");
        _application.ToggleLaunchAtSignInForSmoke();
        Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 2, "The switch did not disable the setting.");
 
        _startupSettings.CanEnable = false;
        _application.VerifySettingsViewForSmoke(false, false, TraySettingsText.StableNativeInstallationRequired);
        _application.ToggleLaunchAtSignInForSmoke();
        Require(_startupSettings.WriteCount == 2, "An unavailable startup setting was enabled.");
        _startupSettings.Store.SetEnabled(true);
        _application.VerifySettingsViewForSmoke(true, true, TraySettingsText.StableNativeInstallationRequired);
        _application.ToggleLaunchAtSignInForSmoke();
        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.VerifySettingsViewForSmoke(false, true, TraySettingsText.StartupDescription);
        _application.ToggleLaunchAtSignInForSmoke();
        Require(!_startupSettings.Store.Read().Enabled && _startupSettings.WriteCount == 4, "A failed write produced a phantom enabled setting.");
        Require(_application.PanelForSmoke.Scene.Accessibility.Single(element => element.Target?.Kind == PanelTargetKind.LaunchAtSignIn)
            .Description!.Contains("Simulated startup write failure.", StringComparison.Ordinal), "A failed write was not reported under the switch.");
        _startupSettings.FailWrite = false;
        _startupSettings.FailRead = true;
        _application.VerifySettingsViewForSmoke(false, false,
            "Could not read launch at sign-in: Simulated startup read failure. Close and reopen Settings to retry.");
        Require(_startupSettings.WriteCount == 4, "Opening an unknown startup state attempted registration.");
        _startupSettings.FailRead = false;
        _application.VerifySettingsViewForSmoke(false, true, TraySettingsText.StartupDescription);
        _application.CloseSettingsViewForSmoke();
        Require(_documentationCalls == 0, "Settings unexpectedly opened documentation.");
        Require(_startupSettings.WriteCount == 4 && !_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);
        }
    }
}