|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using Aspire.TestUtilities;
using Microsoft.Playwright;
using Xunit;
using static Aspire.Templates.Tests.TestExtensions;
namespace Aspire.Templates.Tests;
public partial class TemplateTestsBase
{
[GeneratedRegex(@"^\s*//")]
private static partial Regex CommentLineRegex();
// Regex is from src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets - _GeneratedClassNameFixupRegex
[GeneratedRegex(@"(((?<=\.)|^)(?=\d)|\W)")]
private static partial Regex GeneratedClassNameFixupRegex();
private static Lazy<IBrowser> Browser => new(CreateBrowser);
private static readonly XmlWriterSettings s_xmlWriterSettings = new() { ConformanceLevel = ConformanceLevel.Fragment };
protected readonly TestOutputWrapper _testOutput;
public static readonly string[] TestFrameworkTypes = ["none", "mstest", "nunit", "xunit.net"];
public TemplateTestsBase(ITestOutputHelper testOutput)
{
_testOutput = new TestOutputWrapper(testOutput);
}
private static IBrowser CreateBrowser()
{
var t = Task.Run(async () => await PlaywrightProvider.CreateBrowserAsync());
// default timeout for playwright.Chromium.LaunchAsync is 30secs,
// so using a timeout here as a fallback
if (!t.Wait(45 * 1000))
{
throw new TimeoutException("Browser creation timed out");
}
return t.Result;
}
public async Task<string> CreateAndAddTestTemplateProjectAsync(
string id,
string testTemplateName,
AspireProject project,
TestTargetFramework? tfm = null,
BuildEnvironment? buildEnvironment = null,
string? extraArgs = null,
Func<AspireProject, Task>? onBuildAspireProject = null,
string? overrideRootDir = null)
{
buildEnvironment ??= BuildEnvironment.ForDefaultFramework;
var tmfArg = tfm is not null ? $"-f {tfm.Value.ToTFMString()}" : "";
string rootDirToUse = overrideRootDir ?? project.RootDir;
// Add test project
var testProjectName = $"{id}.{FixupSymbolName(testTemplateName)}Tests";
using var newTestCmd = new DotNetNewCommand(
_testOutput,
label: $"new-test-{testTemplateName}",
buildEnv: buildEnvironment)
.WithWorkingDirectory(rootDirToUse);
var res = await newTestCmd.ExecuteAsync($"{testTemplateName} {tmfArg} -o \"{testProjectName}\" {extraArgs}");
res.EnsureSuccessful();
var testProjectDir = Path.Combine(rootDirToUse, testProjectName);
Assert.True(Directory.Exists(testProjectDir), $"Expected tests project at {testProjectDir}");
var testProjectPath = Path.Combine(testProjectDir, testProjectName + ".csproj");
Assert.True(File.Exists(testProjectPath), $"Expected tests project file at {testProjectPath}");
var appHostProjectName = Path.GetFileName(project.AppHostProjectDirectory)!;
PrepareTestCsFile(
id: project.Id,
projectDir: testProjectDir,
appHostProjectName: appHostProjectName,
testTemplateName: testTemplateName);
PrepareTestProject(
project: project,
projectPath: testProjectPath,
appHostProjectName: appHostProjectName);
return testProjectDir;
static void PrepareTestProject(AspireProject project, string projectPath, string appHostProjectName)
{
// Insert <ProjectReference Include="$(MSBuildThisFileDirectory)..\aspire-starter0.AppHost\aspire-starter0.AppHost.csproj" /> in the project file
// taken from https://raw.githubusercontent.com/dotnet/templating/a325ffa18edd1590f9b340cf83d51d8eb567ebdc/src/Microsoft.TemplateEngine.Orchestrator.RunnableProjects/ValueForms/XmlEncodeValueFormFactory.cs
StringBuilder output = new();
using (var w = XmlWriter.Create(output, s_xmlWriterSettings))
{
w.WriteString(appHostProjectName);
}
var xmlEncodedId = output.ToString();
var projectReference = $@"<ProjectReference Include=""$(MSBuildThisFileDirectory)..\{xmlEncodedId}\{xmlEncodedId}.csproj"" />";
var newContents = File.ReadAllText(projectPath)
.Replace("</Project>", $"<ItemGroup>{projectReference}</ItemGroup>\n</Project>");
File.WriteAllText(projectPath, newContents);
}
static void PrepareTestCsFile(string id, string projectDir, string appHostProjectName, string testTemplateName)
{
var testCsPath = Path.Combine(projectDir, "IntegrationTest1.cs");
var sb = new StringBuilder();
// Uncomment everything after the marker line
var inTest = false;
var marker = testTemplateName switch
{
"aspire-nunit" or "aspire-nunit-9" => "// [Test]",
"aspire-mstest" or "aspire-mstest-9" => "// [TestMethod]",
"aspire-xunit" or "aspire-xunit-9" => "// [Fact]",
_ => throw new NotImplementedException($"Unknown test template: {testTemplateName}")
};
foreach (var line in File.ReadAllLines(testCsPath))
{
if (!inTest && line.Contains(marker))
{
inTest = true;
}
if (inTest && CommentLineRegex().IsMatch(line))
{
sb.AppendLine(CommentLineRegex().Replace(line, " "));
continue;
}
sb.AppendLine(line);
}
var classNameFromId = GeneratedClassNameFixupRegex().Replace(appHostProjectName, "_");
sb.Replace("Projects.MyAspireApp_AppHost", $"Projects.{classNameFromId}");
File.WriteAllText(testCsPath, sb.ToString());
}
}
public static Task<IBrowserContext> CreateNewBrowserContextAsync()
=> PlaywrightProvider.HasPlaywrightSupport
? Browser.Value.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true })
: throw new InvalidOperationException("Playwright is not available");
protected Task<ResourceRow[]> CheckDashboardHasResourcesAsync(WrapperForIPage dashboardPageWrapper, IEnumerable<ResourceRow> expectedResources, string logPath, int timeoutSecs = 120)
=> CheckDashboardHasResourcesAsync(dashboardPageWrapper, expectedResources, _testOutput, logPath, timeoutSecs);
protected static async Task<ResourceRow[]> CheckDashboardHasResourcesAsync(WrapperForIPage dashboardPageWrapper,
IEnumerable<ResourceRow> expectedResources,
ITestOutputHelper testOutput,
string logPath,
int timeoutSecs = 120)
{
try
{
return await CheckDashboardHasResourcesActualAsync(dashboardPageWrapper, expectedResources, testOutput, timeoutSecs);
}
catch
{
string screenshotPath = Path.Combine(logPath, "dashboard-fail.png");
await dashboardPageWrapper.Page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshotPath });
testOutput.WriteLine($"Dashboard screenshot saved to {screenshotPath}");
throw;
}
}
private static async Task<ResourceRow[]> CheckDashboardHasResourcesActualAsync(WrapperForIPage dashboardPageWrapper, IEnumerable<ResourceRow> expectedResources, ITestOutputHelper testOutput, int timeoutSecs = 120)
{
// FIXME: check the page has 'Resources' label
// fluent-toolbar/h1 resources
var numAttempts = 0;
testOutput.WriteLine($"Waiting for resources to appear on the dashboard");
await Task.Delay(500);
var expectedRowsTable = expectedResources.ToDictionary(r => r.Name);
HashSet<string> foundNames = [];
List<ResourceRow> foundRows = [];
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(timeoutSecs));
while (foundNames.Count < expectedRowsTable.Count && !cts.IsCancellationRequested)
{
if (dashboardPageWrapper.HasErrors)
{
if (numAttempts >= 3)
{
throw new InvalidOperationException($"Failed to load dashboard page after {numAttempts} attempts");
}
testOutput.WriteLine($"----- Reloading dashboard page");
await dashboardPageWrapper.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.Load });
numAttempts++;
}
await Task.Delay(500);
// _testOutput.WriteLine($"Checking for rows again");
var rowsLocator = dashboardPageWrapper.Page.Locator("//tr[@class='fluent-data-grid-row hover resource-row']");
var allRows = await rowsLocator.AllAsync();
// _testOutput.WriteLine($"found rows#: {allRows.Count}");
if (allRows.Count == 0)
{
// Console.WriteLine ($"** no rows found ** elapsed: {sw.Elapsed.TotalSeconds} secs");
continue;
}
foreach (var rowLoc in allRows)
{
// get the cells
var cellLocs = await rowLoc.Locator("//td[@role='gridcell']").AllAsync();
// is the resource name expected?
var resourceNameCell = cellLocs[0];
var resourceName = await resourceNameCell.InnerTextAsync();
resourceName = resourceName.Trim();
if (!expectedRowsTable.TryGetValue(resourceName, out var expectedRow))
{
Assert.Fail($"Row with unknown name found: '{resourceName}'. Expected values: {string.Join(", ", expectedRowsTable.Keys.Select(k => $"'{k}'"))}");
}
if (foundNames.Contains(resourceName))
{
continue;
}
AssertEqual(expectedRow.Name, resourceName, $"Name for '{resourceName}'");
var stateCell = cellLocs[1];
var actualState = await stateCell.InnerTextAsync().ConfigureAwait(false);
actualState = actualState.Trim();
if (expectedRow.State != actualState && actualState != "Finished" && !actualState.Contains("failed", StringComparison.OrdinalIgnoreCase))
{
continue;
}
AssertEqual(expectedRow.State, actualState, $"State for {resourceName}");
// Check 'Source' column
var sourceCell = cellLocs[3];
// Since this will be the entire command, we can just confirm that the path of the executable contains
// the expected source (executable/project)
Assert.Contains(expectedRow.SourceContains, await sourceCell.InnerTextAsync());
foundRows.Add(expectedRow);
foundNames.Add(resourceName);
}
}
if (foundNames.Count != expectedRowsTable.Count)
{
Assert.Fail($"Expected rows not found: {string.Join(", ", expectedRowsTable.Keys.Except(foundNames))}");
}
return foundRows.ToArray();
}
// Don't fixup the prefix so it can have characters meant for testing, like spaces
public static string GetNewProjectId(string? prefix = null)
=> (prefix is null ? "" : $"{prefix}_") + FixupSymbolName(Path.GetRandomFileName());
public static IEnumerable<string> GetProjectNamesForTest()
{
if (!PlatformDetection.IsRunningPRValidation)
{
// Avoid running these cases on PR validation
if (!OperatingSystem.IsWindows())
{
// ActiveIssue for windows: https://github.com/dotnet/aspire/issues/4555
yield return "aspire_龦唉丂荳_㐁ᠭ_ᠤསྲིདخەلꌠ_1ᥕ";
}
yield return "aspire_starter.1period then.34letters";
yield return "aspire-starter & with.1";
// ActiveIssue: https://github.com/dotnet/aspnetcore/issues/56277
// yield return "aspire_😀";
}
// basic case
yield return "aspire";
}
public static async Task AssertStarterTemplateRunAsync(IBrowserContext? context, AspireProject project, string config, ITestOutputHelper _testOutput)
{
await project.StartAppHostAsync(extraArgs: [$"-c {config}"], noBuild: false);
if (context is not null)
{
var page = await project.OpenDashboardPageAsync(context);
await CheckDashboardHasResourcesAsync(
page,
StarterTemplateRunTestsBase<StarterTemplateFixture>.GetExpectedResources(project, hasRedisCache: false),
_testOutput,
project.LogPath).ConfigureAwait(false);
string apiServiceUrl = project.InfoTable["apiservice"].Endpoints[0].Uri;
await StarterTemplateRunTestsBase<StarterTemplateFixture>.CheckApiServiceWorksAsync(apiServiceUrl, _testOutput, project.LogPath);
string webFrontEnd = project.InfoTable["webfrontend"].Endpoints[0].Uri;
await StarterTemplateRunTestsBase<StarterTemplateFixture>.CheckWebFrontendWorksAsync(context, webFrontEnd, _testOutput, project.LogPath);
}
else
{
_testOutput.WriteLine($"Skipping playwright part of the test");
}
await project.StopAppHostAsync();
}
public static async Task<CommandResult?> AssertTestProjectRunAsync(string testProjectDirectory, string testType, ITestOutputHelper testOutput, string config = "Debug", int testRunTimeoutSecs = 3 * 60)
{
if (testType == "none")
{
Assert.False(Directory.Exists(testProjectDirectory), "Expected no tests project to be created");
return null;
}
else
{
Assert.True(Directory.Exists(testProjectDirectory), $"Expected tests project at {testProjectDirectory}");
// Build first, because `dotnet test` does not show test results if all the tests pass
using var buildCmd = new DotNetCommand(testOutput, label: $"test-{testType}")
.WithWorkingDirectory(testProjectDirectory)
.WithTimeout(TimeSpan.FromSeconds(testRunTimeoutSecs));
(await buildCmd.ExecuteAsync($"test -c {config}")).EnsureSuccessful();
// .. then test with --no-build
using var testCmd = new DotNetCommand(testOutput, label: $"test-{testType}")
.WithWorkingDirectory(testProjectDirectory)
.WithTimeout(TimeSpan.FromSeconds(testRunTimeoutSecs));
var testRes = (await testCmd.ExecuteAsync($"test -c {config} --no-build"))
.EnsureSuccessful();
Assert.Matches("Passed! * - Failed: *0, Passed: *1, Skipped: *0, Total: *1", testRes.Output);
return testRes;
}
}
public static TheoryData<string, string, TestSdk, TestTargetFramework, string?> TestDataForNewAndBuildTemplateTests(string templateName, string extraArgs) => new()
{
// Previous Sdk, Previous TFM
{ templateName, extraArgs, TestSdk.Previous, TestTargetFramework.Previous, null },
// Previous Sdk - Current TFM
{ templateName, extraArgs, TestSdk.Previous, TestTargetFramework.Current, "The current .NET SDK does not support targeting .NET 9.0" },
// Current SDK, Previous TFM
{ templateName, extraArgs, TestSdk.Current, TestTargetFramework.Previous, null },
// Current SDK, Current TFM
{ templateName, extraArgs, TestSdk.Current, TestTargetFramework.Current, null },
// Current SDK + previous runtime, Previous TFM
{ templateName, extraArgs, TestSdk.CurrentSdkAndPreviousRuntime, TestTargetFramework.Previous, null },
// Current SDK + previous runtime, Current TFM
{ templateName, extraArgs, TestSdk.CurrentSdkAndPreviousRuntime, TestTargetFramework.Current, null },
};
// Taken from dotnet/runtime src/tasks/Common/Utils.cs
private static readonly char[] s_charsToReplace = new[] { '.', '-', '+', '<', '>' };
public static string FixupSymbolName(string name)
{
UTF8Encoding utf8 = new();
byte[] bytes = utf8.GetBytes(name);
StringBuilder sb = new();
foreach (byte b in bytes)
{
if ((b >= (byte)'0' && b <= (byte)'9') ||
(b >= (byte)'a' && b <= (byte)'z') ||
(b >= (byte)'A' && b <= (byte)'Z') ||
(b == (byte)'_'))
{
sb.Append((char)b);
}
else if (s_charsToReplace.Contains((char)b))
{
sb.Append('_');
}
else
{
sb.Append($"_{b:X}_");
}
}
return sb.ToString();
}
}
|