aspnetcore/src/ProjectTemplates/test/Helpers/AspNetProcess.cs

283 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.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using AngleSharp.Dom.Html;
using AngleSharp.Parser.Html;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.AspNetCore.Server.IntegrationTesting;
using Microsoft.Extensions.CommandLineUtils;
using Microsoft.Extensions.Logging.Abstractions;
using OpenQA.Selenium;
using OpenQA.Selenium.Edge;
using Xunit;
using Xunit.Abstractions;
namespace Templates.Test.Helpers
{
[DebuggerDisplay("{ToString(),nq}")]
public class AspNetProcess : IDisposable
{
private const string ListeningMessagePrefix = "Now listening on: ";
private readonly HttpClient _httpClient;
private readonly ITestOutputHelper _output;
internal readonly Uri ListeningUri;
internal ProcessEx Process { get; }
public AspNetProcess(
ITestOutputHelper output,
string workingDirectory,
string dllPath,
IDictionary<string, string> environmentVariables,
bool published = true,
bool hasListeningUri = true)
{
_output = output;
_httpClient = new HttpClient(new HttpClientHandler()
{
AllowAutoRedirect = true,
UseCookies = true,
CookieContainer = new CookieContainer(),
ServerCertificateCustomValidationCallback = (m, c, ch, p) => true,
})
{
Timeout = TimeSpan.FromMinutes(2)
};
var now = DateTimeOffset.Now;
new CertificateManager().EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
output.WriteLine("Running ASP.NET application...");
var arguments = published ? $"exec {dllPath}" : "run";
Process = ProcessEx.Run(output, workingDirectory, DotNetMuxer.MuxerPathOrDefault(), arguments, envVars: environmentVariables);
if (hasListeningUri)
{
ListeningUri = GetListeningUri(output) ?? throw new InvalidOperationException("Couldn't find the listening URL.");
}
}
public void VisitInBrowser(IWebDriver driver)
{
_output.WriteLine($"Opening browser at {ListeningUri}...");
driver.Navigate().GoToUrl(ListeningUri);
if (driver is EdgeDriver)
{
// Workaround for untrusted ASP.NET Core development certificates.
// The edge driver doesn't supported skipping the SSL warning page.
if (driver.Title.Contains("Certificate error", StringComparison.OrdinalIgnoreCase))
{
_output.WriteLine("Page contains certificate error. Attempting to get around this...");
driver.FindElement(By.Id("moreInformationDropdownSpan")).Click();
var continueLink = driver.FindElement(By.Id("invalidcert_continue"));
if (continueLink != null)
{
_output.WriteLine($"Clicking on link '{continueLink.Text}' to skip invalid certificate error page.");
continueLink.Click();
driver.Navigate().GoToUrl(ListeningUri);
}
else
{
_output.WriteLine("Could not find link to skip certificate error page.");
}
}
}
}
public async Task AssertPagesOk(IEnumerable<Page> pages)
{
foreach (var page in pages)
{
await AssertOk(page.Url);
await ContainsLinks(page);
}
}
public async Task ContainsLinks(Page page)
{
var request = new HttpRequestMessage(
HttpMethod.Get,
new Uri(ListeningUri, page.Url));
var response = await RequestWithRetries(client => client.SendAsync(request), _httpClient);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var parser = new HtmlParser();
var html = await parser.ParseAsync(await response.Content.ReadAsStreamAsync());
foreach (IHtmlLinkElement styleSheet in html.GetElementsByTagName("link"))
{
Assert.Equal("stylesheet", styleSheet.Relation);
await AssertOk(styleSheet.Href.Replace("about://", string.Empty));
}
foreach (var script in html.Scripts)
{
if (!string.IsNullOrEmpty(script.Source))
{
await AssertOk(script.Source);
}
}
Assert.True(html.Links.Length == page.Links.Count(), $"Expected {page.Url} to have {page.Links.Count()} links but it had {html.Links.Length}");
foreach ((var link, var expectedLink) in html.Links.Zip(page.Links, Tuple.Create))
{
IHtmlAnchorElement anchor = (IHtmlAnchorElement)link;
if (string.Equals(anchor.Protocol, "about:"))
{
Assert.True(anchor.PathName.EndsWith(expectedLink), $"Expected next link on {page.Url} to be {expectedLink} but it was {anchor.PathName}.");
await AssertOk(anchor.PathName);
}
else
{
Assert.True(string.Equals(anchor.Href, expectedLink), $"Expected next link to be {expectedLink} but it was {anchor.Href}.");
var result = await RetryHelper.RetryRequest(async () =>
{
return await RequestWithRetries(client => client.GetAsync(anchor.Href), _httpClient);
}, logger: NullLogger.Instance);
Assert.True(IsSuccessStatusCode(result), $"{anchor.Href} is a broken link!");
}
}
}
private async Task<T> RequestWithRetries<T>(Func<HttpClient, Task<T>> requester, HttpClient client, int retries = 3, TimeSpan initialDelay = default)
{
var currentDelay = initialDelay == default ? TimeSpan.FromSeconds(30) : initialDelay;
for (int i = 0; i <= retries; i++)
{
try
{
return await requester(client);
}
catch (Exception)
{
if (i == retries)
{
throw;
}
await Task.Delay(currentDelay);
currentDelay *= 2;
}
}
throw new InvalidOperationException("Max retries reached.");
}
private Uri GetListeningUri(ITestOutputHelper output)
{
// Wait until the app is accepting HTTP requests
output.WriteLine("Waiting until ASP.NET application is accepting connections...");
var listeningMessage = GetListeningMessage();
if (!string.IsNullOrEmpty(listeningMessage))
{
listeningMessage = listeningMessage.Trim();
// Verify we have a valid URL to make requests to
var listeningUrlString = listeningMessage.Substring(listeningMessage.IndexOf(
ListeningMessagePrefix, StringComparison.Ordinal) + ListeningMessagePrefix.Length);
output.WriteLine($"Detected that ASP.NET application is accepting connections on: {listeningUrlString}");
listeningUrlString = listeningUrlString.Substring(0, listeningUrlString.IndexOf(':')) +
"://localhost" +
listeningUrlString.Substring(listeningUrlString.LastIndexOf(':'));
output.WriteLine("Sending requests to " + listeningUrlString);
return new Uri(listeningUrlString, UriKind.Absolute);
}
else
{
return null;
}
}
private string GetListeningMessage()
{
try
{
return Process
// This will timeout at most after 5 minutes.
.OutputLinesAsEnumerable
.Where(line => line != null)
// This used to do StartsWith, but this is less strict and can prevent issues (very rare) where
// console logging interleaves with other console output in a bad way. For example:
// dbugNow listening on: http://127.0.0.1:12857
.FirstOrDefault(line => line.Trim().Contains(ListeningMessagePrefix, StringComparison.Ordinal));
}
catch (OperationCanceledException)
{
return null;
}
}
private bool IsSuccessStatusCode(HttpResponseMessage response)
{
return response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Redirect;
}
public Task AssertOk(string requestUrl)
=> AssertStatusCode(requestUrl, HttpStatusCode.OK);
public Task AssertNotFound(string requestUrl)
=> AssertStatusCode(requestUrl, HttpStatusCode.NotFound);
internal Task<HttpResponseMessage> SendRequest(string path)
{
return RequestWithRetries(client => client.GetAsync(new Uri(ListeningUri, path)), _httpClient);
}
public async Task AssertStatusCode(string requestUrl, HttpStatusCode statusCode, string acceptContentType = null)
{
var request = new HttpRequestMessage(
HttpMethod.Get,
new Uri(ListeningUri, requestUrl));
if (!string.IsNullOrEmpty(acceptContentType))
{
request.Headers.Add("Accept", acceptContentType);
}
var response = await RequestWithRetries(client => client.SendAsync(request), _httpClient);
Assert.True(statusCode == response.StatusCode, $"Expected {requestUrl} to have status '{statusCode}' but it was '{response.StatusCode}'.");
}
public void Dispose()
{
_httpClient.Dispose();
Process.Dispose();
}
public override string ToString()
{
var result = "";
result += Process != null ? "Active: " : "Inactive";
if (Process != null)
{
if (!Process.HasExited)
{
result += $"(Listening on {ListeningUri.OriginalString}) PID: {Process.Id}";
}
else
{
result += "(Already finished)";
}
}
return result;
}
}
public class Page
{
public string Url { get; set; }
public IEnumerable<string> Links { get; set; }
}
}