File: TestUtilities\WatchableApp.cs
Web Access
Project: ..\..\..\test\dotnet-watch.Tests\dotnet-watch.Tests.csproj (dotnet-watch.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable disable
 
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
 
namespace Microsoft.DotNet.Watch.UnitTests
{
    internal sealed class WatchableApp(DebugTestOutputLogger logger) : IDisposable
    {
        // Test apps should output this message as soon as they start running:
        private const string StartedMessage = "Started";
 
        // Test apps should output this message as soon as they exit:
        private const string ExitingMessage = "Exiting";
 
        private const string WatchErrorOutputEmoji = "❌";
        private const string WatchFileChanged = "dotnet watch ⌚ File changed:";
 
        public TestFlags TestFlags { get; private set; }
 
        public DebugTestOutputLogger Logger => logger;
 
        public AwaitableProcess Process { get; private set; }
 
        public List<string> DotnetWatchArgs { get; } = ["--verbose", "-bl"];
 
        public Dictionary<string, string> EnvironmentVariables { get; } = [];
 
        public void AssertOutputContains(string message)
            => AssertEx.ContainsSubstring(message, Process.Output);
 
        public void AssertOutputDoesNotContain(string message)
            => Assert.DoesNotContain(Process.Output, line => line.Contains(message));
 
        public void AssertOutputContains(Regex pattern)
            => AssertEx.ContainsPattern(pattern, Process.Output);
 
        public void AssertOutputContains(MessageDescriptor descriptor, string projectDisplay = null)
            => AssertOutputContains(GetPattern(descriptor, projectDisplay));
 
        private static Regex GetPattern(MessageDescriptor descriptor, string projectDisplay = null)
            => new Regex(Regex.Replace(Regex.Escape((projectDisplay != null ? $"[{projectDisplay}] " : "") + descriptor.Format), @"\\\{[0-9]+\}", ".*"));
 
        public async ValueTask WaitUntilOutputContains(string text, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
        {
            if (Process.Output.Any(line => line.Contains(text)))
            {
                Logger.Log($"Test found output: '{text}'", testPath, testLine);
            }
            else   
            {
                Logger.Log($"Test waiting for output: '{text}'", testPath, testLine);
                _ = await WaitForOutputLineMatching(line => line.Contains(text));
            }
        }
 
        public async ValueTask WaitUntilOutputContains(Regex pattern, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
        {
            if (Process.Output.Any(line => pattern.IsMatch(line)))
            {
                Logger.Log($"Test found output pattern: '{pattern}'", testPath, testLine);
            }
            else
            {
                Logger.Log($"Test waiting for output pattern: '{pattern}'", testPath, testLine);
                _ = await WaitForOutputLineMatching(line => pattern.IsMatch(line));
            }
        }
 
        public async ValueTask WaitUntilOutputContains(MessageDescriptor descriptor, string projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string testPath = null)
        {
            var pattern = GetPattern(descriptor, projectDisplay);
            if (Process.Output.Any(line => pattern.IsMatch(line)))
            {
                Logger.Log($"Test found output text format: '{descriptor.Format}'", testPath, testLine);
            }
            else
            {
                Logger.Log($"Test waiting for output text format: '{descriptor.Format}'", testPath, testLine);
                _ = await WaitForOutputLineMatching(line => pattern.IsMatch(line));
            }
        }
 
        public Task<string> WaitForOutputLineContaining(string text, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
        {
            Logger.Log($"Test waiting for output: '{text}'", testPath, testLine);
            return Process.GetOutputLineAsync(success: line => line.Contains(text), failure: _ => false);
        }
 
        public Task<string> WaitForOutputLineContaining(MessageDescriptor descriptor, string projectDisplay = null, [CallerLineNumber] int testLine = 0, [CallerFilePath] string testPath = null)
        {
            Logger.Log($"Test waiting for text format: '{descriptor.Format}'", testPath, testLine);
 
            var pattern = GetPattern(descriptor, projectDisplay);
            return Process.GetOutputLineAsync(success: line => pattern.IsMatch(line), failure: _ => false);
        }
 
        public Task<string> WaitForOutputLineContaining(Regex pattern, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
        {
            Logger.Log($"Test waiting for output pattern: '{pattern}'", testPath, testLine);
            return Process.GetOutputLineAsync(success: line => pattern.IsMatch(line), failure: _ => false);
        }
 
        private Task<string> WaitForOutputLineMatching(Predicate<string> predicate)
            => Process.GetOutputLineAsync(success: predicate, failure: _ => false);
 
        /// <summary>
        /// Asserts that the watched process outputs a line starting with <paramref name="expectedPrefix"/> and returns the remainder of that line.
        /// </summary>
        public async Task<string> AssertOutputLineStartsWith(string expectedPrefix, Predicate<string> failure = null, [CallerFilePath] string testPath = null, [CallerLineNumber] int testLine = 0)
        {
            Logger.Log($"Test waiting for output: '{expectedPrefix}'", testPath, testLine);
 
            var line = await Process.GetOutputLineAsync(
                success: line => line.StartsWith(expectedPrefix, StringComparison.Ordinal),
                failure: failure ?? new Predicate<string>(line => line.Contains(WatchErrorOutputEmoji, StringComparison.Ordinal)));
 
            if (line == null)
            {
                Assert.Fail(failure != null
                    ? "Encountered failure condition"
                    : $"Failed to find expected prefix: '{expectedPrefix}'");
            }
            else
            {
                Assert.StartsWith(expectedPrefix, line, StringComparison.Ordinal);
            }
 
            return line.Substring(expectedPrefix.Length);
        }
 
        public async Task AssertOutputLineEquals(string expectedLine)
            => Assert.Equal("", await AssertOutputLineStartsWith(expectedLine));
 
        public Task AssertStarted()
            => AssertOutputLineEquals(StartedMessage);
 
        public Task AssertFileChanged()
            => AssertOutputLineStartsWith(WatchFileChanged);
 
        public Task AssertExiting()
            => AssertOutputLineStartsWith(ExitingMessage);
 
        public void Start(TestAsset asset, IEnumerable<string> arguments, string relativeProjectDirectory = null, string workingDirectory = null, TestFlags testFlags = TestFlags.RunningAsTest)
        {
            if (testFlags != TestFlags.None)
            {
                testFlags |= TestFlags.RunningAsTest;
            }
 
            var projectDirectory = (relativeProjectDirectory != null) ? Path.Combine(asset.Path, relativeProjectDirectory) : asset.Path;
 
            var commandSpec = new DotnetCommand(Logger, ["watch", .. DotnetWatchArgs, .. arguments])
            {
                WorkingDirectory = workingDirectory ?? projectDirectory,
            };
 
            var testOutputPath = asset.GetWatchTestOutputPath();
            Directory.CreateDirectory(testOutputPath);
 
            // FileSystemWatcher is unreliable. Use polling for testing to avoid flakiness.
            commandSpec.WithEnvironmentVariable("DOTNET_USE_POLLING_FILE_WATCHER", "true");
 
            commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS", testFlags.ToString());
            commandSpec.WithEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR", testOutputPath);
            commandSpec.WithEnvironmentVariable("Microsoft_CodeAnalysis_EditAndContinue_LogDir", testOutputPath);
 
            // suppress all timeouts:
            commandSpec.WithEnvironmentVariable("DCP_IDE_REQUEST_TIMEOUT_SECONDS", "100000");
            commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS", "100000");
            commandSpec.WithEnvironmentVariable("DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS", "100000");
            commandSpec.WithEnvironmentVariable("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "1");
 
            foreach (var env in EnvironmentVariables)
            {
                commandSpec.WithEnvironmentVariable(env.Key, env.Value);
            }
 
            Process = new AwaitableProcess(commandSpec, Logger);
            Process.Start();
 
            TestFlags = testFlags;
        }
 
        public void Dispose()
        {
            Process?.Dispose();
        }
 
        public void SendControlC()
            => SendKey(PhysicalConsole.CtrlC);
 
        public void SendControlR()
            => SendKey(PhysicalConsole.CtrlR);
 
        public void SendKey(char c)
        {
            Assert.True(TestFlags.HasFlag(TestFlags.ReadKeyFromStdin));
 
            Process.Process.StandardInput.Write(c);
            Process.Process.StandardInput.Flush();
        }
    }
}