aspnetcore/src/Shared/E2ETesting/SeleniumStandaloneServer.cs

298 lines
11 KiB
C#

// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
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 SeleniumStandaloneServer : IDisposable
{
private static SemaphoreSlim _semaphore = new SemaphoreSlim(1);
private Process _process;
private string _sentinelPath;
private Process _sentinelProcess;
private static IMessageSink _diagnosticsMessageSink;
// 1h 30 min
private static int SeleniumProcessTimeout = 3600;
public SeleniumStandaloneServer(IMessageSink diagnosticsMessageSink)
{
if (Instance != null || _diagnosticsMessageSink != null)
{
throw new InvalidOperationException("Selenium standalone 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(
Uri uri,
Process process,
string sentinelPath,
Process sentinelProcess)
{
Uri = uri;
_process = process;
_sentinelPath = sentinelPath;
_sentinelProcess = sentinelProcess;
}
public Uri Uri { get; private set; }
internal static SeleniumStandaloneServer Instance { get; private set; }
public static async Task<SeleniumStandaloneServer> GetInstanceAsync(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();
}
return Instance;
}
private static async Task InitializeInstance(ITestOutputHelper output)
{
var port = FindAvailablePort();
var uri = new UriBuilder("http", "localhost", port, "/wd/hub").Uri;
var seleniumConfigPath = typeof(SeleniumStandaloneServer).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(k => k.Key == "Microsoft.AspNetCore.Testing.SeleniumConfigPath")
?.Value;
if (seleniumConfigPath == null)
{
throw new InvalidOperationException("Selenium config path not configured. Does this project import the E2ETesting.targets?");
}
var psi = new ProcessStartInfo
{
FileName = "npm",
Arguments = $"run selenium-standalone start -- --config \"{seleniumConfigPath}\" -- -port {port}",
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 (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("helix")))
{
// Just create a random tracking folder on helix
trackingFolder = Path.Combine(Directory.GetCurrentDirectory(), Path.GetRandomFileName());
Directory.CreateDirectory(trackingFolder);
}
if (!Directory.Exists(trackingFolder))
{
throw new InvalidOperationException($"Invalid tracking folder. Set the 'SeleniumProcessTrackingFolder' 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, SeleniumProcessTimeout);
}
catch
{
ProcessCleanup(process, pidFilePath);
ProcessCleanup(sentinel, pidFilePath: null);
throw;
}
// Log output for selenium standalone 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 Selenium sever 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 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(uri, process, pidFilePath, sentinel);
return;
}
}
catch (OperationCanceledException)
{
}
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() : "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 Selenium 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 selenium server that want' 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());
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}");
}
static int FindAvailablePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
try
{
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
private static string GetProcessTrackingFolder() =>
typeof(SeleniumStandaloneServer).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(a => a.Key == "Microsoft.AspNetCore.Testing.Selenium.ProcessTracking").Value;
public void Dispose()
{
ProcessCleanup(_process, _sentinelPath);
ProcessCleanup(_sentinelProcess, pidFilePath: null);
}
}
}