File: src\Shared\E2ETesting\SauceConnectServer.cs
Web Access
Project: src\src\ProjectTemplates\test\Templates.Mvc.Tests\Templates.Mvc.Tests.csproj (Templates.Mvc.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.E2ETesting;
using Microsoft.Extensions.Internal;
using Xunit.Abstractions;
using Xunit.Sdk;
 
namespace Microsoft.AspNetCore.E2ETesting;
 
public class SauceConnectServer : IDisposable
{
    private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);
 
    private Process _process;
    private string _sentinelPath;
    private Process _sentinelProcess;
    private static IMessageSink _diagnosticsMessageSink;
 
    // 2h
    private const int SauceConnectProcessTimeout = 7200;
 
    public SauceConnectServer(IMessageSink diagnosticsMessageSink)
    {
        if (Instance != null || _diagnosticsMessageSink != null)
        {
            throw new InvalidOperationException("Sauce connect singleton already created.");
        }
 
        // The assembly level attribute AssemblyFixture takes care of this being being instantiated before tests run
        // and disposed after tests are run, gracefully shutting down the server when possible by calling Dispose on
        // the singleton.
        Instance = this;
        _diagnosticsMessageSink = diagnosticsMessageSink;
    }
 
    private void Initialize(
        Process process,
        string sentinelPath,
        Process sentinelProcess)
    {
        _process = process;
        _sentinelPath = sentinelPath;
        _sentinelProcess = sentinelProcess;
    }
 
    internal static SauceConnectServer Instance { get; private set; }
 
    public static async Task StartAsync(ITestOutputHelper output)
    {
        try
        {
            await _semaphore.WaitAsync();
            if (Instance._process == null)
            {
                // No process was started, meaning the instance wasn't initialized.
                await InitializeInstance(output);
            }
        }
        finally
        {
            _semaphore.Release();
        }
    }
 
    private static async Task InitializeInstance(ITestOutputHelper output)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "npm",
            Arguments = "run sauce --" +
                $" --sauce-user {E2ETestOptions.Instance.Sauce.Username}" +
                $" --sauce-key {E2ETestOptions.Instance.Sauce.AccessKey}" +
                $" --sauce-tunnel {E2ETestOptions.Instance.Sauce.TunnelIdentifier}" +
                $" --use-hostname {E2ETestOptions.Instance.Sauce.HostName}",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };
 
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            psi.FileName = "cmd";
            psi.Arguments = $"/c npm {psi.Arguments}";
        }
 
        // It's important that we get the folder value before we start the process to prevent
        // untracked processes when the tracking folder is not correctly configure.
        var trackingFolder = GetProcessTrackingFolder();
        if (!Directory.Exists(trackingFolder))
        {
            throw new InvalidOperationException($"Invalid tracking folder. Set the 'SauceConnectProcessTrackingFolder' MSBuild property to a valid folder.");
        }
 
        Process process = null;
        Process sentinel = null;
        string pidFilePath = null;
        try
        {
            process = Process.Start(psi);
            pidFilePath = await WriteTrackingFileAsync(output, trackingFolder, process);
            sentinel = StartSentinelProcess(process, pidFilePath, SauceConnectProcessTimeout);
        }
        catch
        {
            ProcessCleanup(process, pidFilePath);
            ProcessCleanup(sentinel, pidFilePath: null);
            throw;
        }
 
        // Log output for sauce connect process.
        // This is for the case where the server fails to launch.
        var logOutput = new BlockingCollection<string>();
 
        process.OutputDataReceived += LogOutput;
        process.ErrorDataReceived += LogOutput;
 
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();
 
        // The Sauce connect server has to be up for the entirety of the tests and is only shutdown when the application (i.e. the test) exits.
        AppDomain.CurrentDomain.ProcessExit += (sender, args) => ProcessCleanup(process, pidFilePath);
 
        // Log
        void LogOutput(object sender, DataReceivedEventArgs e)
        {
            logOutput.TryAdd(e.Data);
 
            // We avoid logging on the output here because it is unreliable. We can only log in the diagnostics sink.
            lock (_diagnosticsMessageSink)
            {
                _diagnosticsMessageSink.OnMessage(new DiagnosticMessage(e.Data));
            }
        }
 
        var uri = new UriBuilder("http", E2ETestOptions.Instance.Sauce.HostName, 4445).Uri;
        var httpClient = new HttpClient
        {
            Timeout = TimeSpan.FromSeconds(1),
        };
 
        var retries = 0;
        do
        {
            await Task.Delay(1000);
            try
            {
                var response = await httpClient.GetAsync(uri);
                if (response.StatusCode == HttpStatusCode.OK)
                {
                    output = null;
                    Instance.Initialize(process, pidFilePath, sentinel);
                    return;
                }
            }
            catch (OperationCanceledException)
            {
            }
            catch (HttpRequestException)
            {
            }
 
            retries++;
        } while (retries < 30);
 
        // Make output null so that we stop logging to it.
        output = null;
        logOutput.CompleteAdding();
        var exitCodeString = process.HasExited ? process.ExitCode.ToString(CultureInfo.InvariantCulture) : "Process has not yet exited.";
        var message = $@"Failed to launch the server.
ExitCode: {exitCodeString}
Captured output lines:
{string.Join(Environment.NewLine, logOutput.GetConsumingEnumerable())}.";
 
        // If we got here, we couldn't launch Sauce connect or get it to respond. So shut it down.
        ProcessCleanup(process, pidFilePath);
        throw new InvalidOperationException(message);
    }
 
    private static Process StartSentinelProcess(Process process, string sentinelFile, int timeout)
    {
        // This sentinel process will start and will kill any rouge sauce connect server that wasn't torn down via normal means.
        var psi = new ProcessStartInfo
        {
            FileName = "powershell",
            Arguments = $"-NoProfile -NonInteractive -Command \"Start-Sleep {timeout}; " +
            $"if(Test-Path {sentinelFile}){{ " +
            $"Write-Output 'Stopping process {process.Id}'; Stop-Process {process.Id}; }}" +
            $"else{{ Write-Output 'Sentinel file {sentinelFile} not found.'}}",
        };
 
        return Process.Start(psi);
    }
 
    private static void ProcessCleanup(Process process, string pidFilePath)
    {
        try
        {
            if (process?.HasExited == false)
            {
                try
                {
                    process?.KillTree(TimeSpan.FromSeconds(10));
                    process?.Dispose();
                }
                catch
                {
                    // Ignore errors here since we can't do anything
                }
            }
            if (pidFilePath != null && File.Exists(pidFilePath))
            {
                File.Delete(pidFilePath);
            }
        }
        catch
        {
            // Ignore errors here since we can't do anything
        }
    }
 
    private static async Task<string> WriteTrackingFileAsync(ITestOutputHelper output, string trackingFolder, Process process)
    {
        var pidFile = Path.Combine(trackingFolder, $"{process.Id}.{Guid.NewGuid()}.pid");
        for (var i = 0; i < 3; i++)
        {
            try
            {
                await File.WriteAllTextAsync(pidFile, process.Id.ToString(CultureInfo.InvariantCulture));
                return pidFile;
            }
            catch
            {
                output.WriteLine($"Can't write file to process tracking folder: {trackingFolder}");
            }
        }
 
        throw new InvalidOperationException($"Failed to write file for process {process.Id}");
    }
 
    private static string GetProcessTrackingFolder() =>
        typeof(SauceConnectServer).Assembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .Single(a => a.Key == "Microsoft.AspNetCore.InternalTesting.SauceConnect.ProcessTracking").Value;
 
    public void Dispose()
    {
        ProcessCleanup(_process, _sentinelPath);
        ProcessCleanup(_sentinelProcess, pidFilePath: null);
    }
}