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

493 lines
18 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.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.CommandLineUtils;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Templates.Test.Helpers
{
[DebuggerDisplay("{ToString(),nq}")]
public class Project
{
private const string _urls = "http://127.0.0.1:0;https://127.0.0.1:0";
public const string DefaultFramework = "netcoreapp3.0";
public SemaphoreSlim DotNetNewLock { get; set; }
public SemaphoreSlim NodeLock { get; set; }
public string ProjectName { get; set; }
public string ProjectArguments { get; set; }
public string ProjectGuid { get; set; }
public string TemplateOutputDir { get; set; }
public string TemplateBuildDir => Path.Combine(TemplateOutputDir, "bin", "Debug", DefaultFramework);
public string TemplatePublishDir => Path.Combine(TemplateOutputDir, "bin", "Release", DefaultFramework, "publish");
private string TemplateServerDir => Path.Combine(TemplateOutputDir, $"{ProjectName}.Server");
private string TemplateClientDir => Path.Combine(TemplateOutputDir, $"{ProjectName}.Client");
public string TemplateClientDebugDir => Path.Combine(TemplateClientDir, "bin", "Debug", DefaultFramework);
public string TemplateClientReleaseDir => Path.Combine(TemplateClientDir, "bin", "Release", DefaultFramework, "publish");
public string TemplateServerReleaseDir => Path.Combine(TemplateServerDir, "bin", "Release", DefaultFramework, "publish");
public ITestOutputHelper Output { get; set; }
public IMessageSink DiagnosticsMessageSink { get; set; }
internal async Task<ProcessEx> RunDotNetNewAsync(string templateName, string auth = null, string language = null, bool useLocalDB = false, bool noHttps = false)
{
var hiveArg = $"--debug:custom-hive \"{TemplatePackageInstaller.CustomHivePath}\"";
var args = $"new {templateName} {hiveArg}";
if (!string.IsNullOrEmpty(auth))
{
args += $" --auth {auth}";
}
if (!string.IsNullOrEmpty(language))
{
args += $" -lang {language}";
}
if (useLocalDB)
{
args += $" --use-local-db";
}
if (noHttps)
{
args += $" --no-https";
}
// Save a copy of the arguments used for better diagnostic error messages later.
// We omit the hive argument and the template output dir as they are not relevant and add noise.
ProjectArguments = args.Replace(hiveArg, "");
args += $" -o {TemplateOutputDir}";
// Only run one instance of 'dotnet new' at once, as a workaround for
// https://github.com/aspnet/templating/issues/63
await DotNetNewLock.WaitAsync();
try
{
var execution = ProcessEx.Run(Output, AppContext.BaseDirectory, DotNetMuxer.MuxerPathOrDefault(), args);
await execution.Exited;
return execution;
}
finally
{
DotNetNewLock.Release();
}
}
internal async Task<ProcessEx> RunDotNetPublishAsync(bool takeNodeLock = false)
{
Output.WriteLine("Publishing ASP.NET application...");
// Workaround for issue with runtime store not yet being published
// https://github.com/aspnet/Home/issues/2254#issuecomment-339709628
var extraArgs = "-p:PublishWithAspNetCoreTargetManifest=false";
// This is going to trigger a build, so we need to acquire the lock like in the other cases.
// We want to take the node lock as some builds run NPM as part of the build and we want to make sure
// it's run without interruptions.
var effectiveLock = takeNodeLock ? new OrderedLock(NodeLock, DotNetNewLock) : new OrderedLock(nodeLock: null, DotNetNewLock);
await effectiveLock.WaitAsync();
try
{
var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish -c Release {extraArgs}");
await result.Exited;
return result;
}
finally
{
effectiveLock.Release();
}
}
internal async Task<ProcessEx> RunDotNetBuildAsync(bool takeNodeLock = false)
{
Output.WriteLine("Building ASP.NET application...");
// This is going to trigger a build, so we need to acquire the lock like in the other cases.
// We want to take the node lock as some builds run NPM as part of the build and we want to make sure
// it's run without interruptions.
var effectiveLock = takeNodeLock ? new OrderedLock(NodeLock, DotNetNewLock) : new OrderedLock(nodeLock: null, DotNetNewLock);
await effectiveLock.WaitAsync();
try
{
var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), "build -c Debug");
await result.Exited;
return result;
}
finally
{
effectiveLock.Release();
}
}
internal AspNetProcess StartBuiltServerAsync()
{
var environment = new Dictionary<string, string>
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
};
var projectDll = Path.Combine(TemplateServerDir, $"{ProjectName}.Server.dll");
return new AspNetProcess(Output, TemplateServerDir, projectDll, environment, published: false);
}
internal AspNetProcess StartBuiltClientAsync(AspNetProcess serverProcess)
{
var environment = new Dictionary<string, string>
{
["ASPNETCORE_ENVIRONMENT"] = "Development"
};
var projectDll = Path.Combine(TemplateClientDebugDir, $"{ProjectName}.Client.dll {serverProcess.ListeningUri.Port}");
return new AspNetProcess(Output, TemplateOutputDir, projectDll, environment, hasListeningUri: false);
}
internal AspNetProcess StartPublishedServerAsync()
{
var environment = new Dictionary<string, string>
{
["ASPNETCORE_URLS"] = _urls,
};
var projectDll = $"{ProjectName}.Server.dll";
return new AspNetProcess(Output, TemplateServerReleaseDir, projectDll, environment);
}
internal AspNetProcess StartPublishedClientAsync()
{
var environment = new Dictionary<string, string>
{
["ASPNETCORE_URLS"] = _urls,
};
var projectDll = $"{ProjectName}.Client.dll";
return new AspNetProcess(Output, TemplateClientReleaseDir, projectDll, environment);
}
internal AspNetProcess StartBuiltProjectAsync(bool hasListeningUri = true)
{
var environment = new Dictionary<string, string>
{
["ASPNETCORE_URLS"] = _urls,
["ASPNETCORE_ENVIRONMENT"] = "Development",
["ASPNETCORE_Kestrel__EndpointDefaults__Protocols"] = "Http1"
};
var projectDll = Path.Combine(TemplateBuildDir, $"{ProjectName}.dll");
return new AspNetProcess(Output, TemplateOutputDir, projectDll, environment, hasListeningUri: hasListeningUri);
}
internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true)
{
var environment = new Dictionary<string, string>
{
["ASPNETCORE_URLS"] = _urls,
["ASPNETCORE_Kestrel__EndpointDefaults__Protocols"] = "Http1"
};
var projectDll = $"{ProjectName}.dll";
return new AspNetProcess(Output, TemplatePublishDir, projectDll, environment, hasListeningUri: hasListeningUri);
}
internal async Task<ProcessEx> RestoreWithRetryAsync(ITestOutputHelper output, string workingDirectory)
{
// "npm restore" sometimes fails randomly in AppVeyor with errors like:
// EPERM: operation not permitted, scandir <path>...
// This appears to be a general NPM reliability issue on Windows which has
// been reported many times (e.g., https://github.com/npm/npm/issues/18380)
// So, allow multiple attempts at the restore.
const int maxAttempts = 3;
var attemptNumber = 0;
ProcessEx restoreResult;
do
{
restoreResult = await RestoreAsync(output, workingDirectory);
if (restoreResult.ExitCode == 0)
{
return restoreResult;
}
else
{
// TODO: We should filter for EPEM here to avoid masking other errors silently.
output.WriteLine(
$"NPM restore in {workingDirectory} failed on attempt {attemptNumber} of {maxAttempts}. " +
$"Error was: {restoreResult.GetFormattedOutput()}");
// Clean up the possibly-incomplete node_modules dir before retrying
CleanNodeModulesFolder(workingDirectory, output);
}
attemptNumber++;
} while (attemptNumber < maxAttempts);
output.WriteLine($"Giving up attempting NPM restore in {workingDirectory} after {attemptNumber} attempts.");
return restoreResult;
void CleanNodeModulesFolder(string workingDirectory, ITestOutputHelper output)
{
var nodeModulesDir = Path.Combine(workingDirectory, "node_modules");
try
{
if (Directory.Exists(nodeModulesDir))
{
Directory.Delete(nodeModulesDir, recursive: true);
}
}
catch
{
output.WriteLine($"Failed to clean up node_modules folder at {nodeModulesDir}.");
}
}
}
private async Task<ProcessEx> RestoreAsync(ITestOutputHelper output, string workingDirectory)
{
// It's not safe to run multiple NPM installs in parallel
// https://github.com/npm/npm/issues/2500
await NodeLock.WaitAsync();
try
{
output.WriteLine($"Restoring NPM packages in '{workingDirectory}' using npm...");
var result = await ProcessEx.RunViaShellAsync(output, workingDirectory, "npm install");
return result;
}
finally
{
NodeLock.Release();
}
}
internal async Task<ProcessEx> RunDotNetEfCreateMigrationAsync(string migrationName)
{
var assembly = typeof(ProjectFactoryFixture).Assembly;
var dotNetEfFullPath = assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.First(attribute => attribute.Key == "DotNetEfFullPath")
.Value;
var args = $"\"{dotNetEfFullPath}\" --verbose --no-build migrations add {migrationName}";
// Only run one instance of 'dotnet new' at once, as a workaround for
// https://github.com/aspnet/templating/issues/63
await DotNetNewLock.WaitAsync();
try
{
var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), args);
await result.Exited;
return result;
}
finally
{
DotNetNewLock.Release();
}
}
internal async Task<ProcessEx> RunDotNetEfUpdateDatabaseAsync()
{
var assembly = typeof(ProjectFactoryFixture).Assembly;
var dotNetEfFullPath = assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
.First(attribute => attribute.Key == "DotNetEfFullPath")
.Value;
var args = $"\"{dotNetEfFullPath}\" --verbose --no-build database update";
// Only run one instance of 'dotnet new' at once, as a workaround for
// https://github.com/aspnet/templating/issues/63
await DotNetNewLock.WaitAsync();
try
{
var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), args);
await result.Exited;
return result;
}
finally
{
DotNetNewLock.Release();
}
}
// If this fails, you should generate new migrations via migrations/updateMigrations.cmd
public void AssertEmptyMigration(string migration)
{
var fullPath = Path.Combine(TemplateOutputDir, "Data/Migrations");
var file = Directory.EnumerateFiles(fullPath).Where(f => f.EndsWith($"{migration}.cs")).FirstOrDefault();
Assert.NotNull(file);
var contents = File.ReadAllText(file);
var emptyMigration = @"protected override void Up(MigrationBuilder migrationBuilder)
{
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}";
// This comparison can break depending on how GIT checked out newlines on different files.
Assert.Contains(RemoveNewLines(emptyMigration), RemoveNewLines(contents));
static string RemoveNewLines(string str)
{
return str.Replace("\n", string.Empty).Replace("\r", string.Empty);
}
}
public void AssertFileExists(string path, bool shouldExist)
{
var fullPath = Path.Combine(TemplateOutputDir, path);
var doesExist = File.Exists(fullPath);
if (shouldExist)
{
Assert.True(doesExist, "Expected file to exist, but it doesn't: " + path);
}
else
{
Assert.False(doesExist, "Expected file not to exist, but it does: " + path);
}
}
public string ReadFile(string path)
{
AssertFileExists(path, shouldExist: true);
return File.ReadAllText(Path.Combine(TemplateOutputDir, path));
}
internal async Task<ProcessEx> RunDotNetNewRawAsync(string arguments)
{
await DotNetNewLock.WaitAsync();
try
{
var result = ProcessEx.Run(
Output,
AppContext.BaseDirectory,
DotNetMuxer.MuxerPathOrDefault(),
arguments +
$" --debug:custom-hive \"{TemplatePackageInstaller.CustomHivePath}\"" +
$" -o {TemplateOutputDir}");
await result.Exited;
return result;
}
finally
{
DotNetNewLock.Release();
}
}
public void Dispose()
{
DeleteOutputDirectory();
}
public void DeleteOutputDirectory()
{
const int NumAttempts = 10;
for (var numAttemptsRemaining = NumAttempts; numAttemptsRemaining > 0; numAttemptsRemaining--)
{
try
{
Directory.Delete(TemplateOutputDir, true);
return;
}
catch (Exception ex)
{
if (numAttemptsRemaining > 1)
{
DiagnosticsMessageSink.OnMessage(new DiagnosticMessage($"Failed to delete directory {TemplateOutputDir} because of error {ex.Message}. Will try again {numAttemptsRemaining - 1} more time(s)."));
Thread.Sleep(3000);
}
else
{
DiagnosticsMessageSink.OnMessage(new DiagnosticMessage($"Giving up trying to delete directory {TemplateOutputDir} after {NumAttempts} attempts. Most recent error was: {ex.StackTrace}"));
}
}
}
}
private class OrderedLock
{
private bool _nodeLockTaken;
private bool _dotNetLockTaken;
public OrderedLock(SemaphoreSlim nodeLock, SemaphoreSlim dotnetLock)
{
NodeLock = nodeLock;
DotnetLock = dotnetLock;
}
public SemaphoreSlim NodeLock { get; }
public SemaphoreSlim DotnetLock { get; }
public async Task WaitAsync()
{
if (NodeLock == null)
{
await DotnetLock.WaitAsync();
_dotNetLockTaken = true;
return;
}
try
{
// We want to take the NPM lock first as is going to be the busiest one, and we want other threads to be
// able to run dotnet new while we are waiting for another thread to finish running NPM.
await NodeLock.WaitAsync();
_nodeLockTaken = true;
await DotnetLock.WaitAsync();
_dotNetLockTaken = true;
}
catch
{
if (_nodeLockTaken)
{
NodeLock.Release();
_nodeLockTaken = false;
}
throw;
}
}
public void Release()
{
try
{
if (_dotNetLockTaken)
{
DotnetLock.Release();
_dotNetLockTaken = false;
}
}
finally
{
if (_nodeLockTaken)
{
NodeLock.Release();
_nodeLockTaken = false;
}
}
}
}
public override string ToString() => $"{ProjectName}: {TemplateOutputDir}";
}
}