// 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.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.E2ETesting; using Newtonsoft.Json.Linq; using OpenQA.Selenium; using Templates.Test.Helpers; using Xunit; using Xunit.Abstractions; // Turn off parallel test run for Edge as the driver does not support multiple Selenium tests at the same time #if EDGE [assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)] #endif namespace Templates.Test.SpaTemplateTest { public class SpaTemplateTestBase : BrowserTestBase { public SpaTemplateTestBase( ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output) : base(browserFixture, output) { ProjectFactory = projectFactory; } public ProjectFactoryFixture ProjectFactory { get; set; } public Project Project { get; set; } // Rather than using [Theory] to pass each of the different values for 'template', // it's important to distribute the SPA template tests over different test classes // so they can be run in parallel. Xunit doesn't parallelize within a test class. protected async Task SpaTemplateImplAsync( string key, string template, bool useLocalDb = false, bool usesAuth = false) { Project = await ProjectFactory.GetOrCreateProject(key, Output); using var createResult = await Project.RunDotNetNewAsync(template, auth: usesAuth ? "Individual" : null, language: null, useLocalDb); Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult)); // We shouldn't have to do the NPM restore in tests because it should happen // automatically at build time, but by doing it up front we can avoid having // multiple NPM installs run concurrently which otherwise causes errors when // tests run in parallel. var clientAppSubdirPath = Path.Combine(Project.TemplateOutputDir, "ClientApp"); ValidatePackageJson(clientAppSubdirPath); var projectFileContents = ReadFile(Project.TemplateOutputDir, $"{Project.ProjectName}.csproj"); if (usesAuth && !useLocalDb) { Assert.Contains(".db", projectFileContents); } using var npmRestoreResult = await Project.RestoreWithRetryAsync(Output, clientAppSubdirPath); Assert.True(0 == npmRestoreResult.ExitCode, ErrorMessages.GetFailedProcessMessage("npm restore", Project, npmRestoreResult)); using var lintResult = await ProcessEx.RunViaShellAsync(Output, clientAppSubdirPath, "npm run lint"); Assert.True(0 == lintResult.ExitCode, ErrorMessages.GetFailedProcessMessage("npm run lint", Project, lintResult)); if (template == "react" || template == "reactredux") { using var testResult = await ProcessEx.RunViaShellAsync(Output, clientAppSubdirPath, "npm run test"); Assert.True(0 == testResult.ExitCode, ErrorMessages.GetFailedProcessMessage("npm run test", Project, testResult)); } using var publishResult = await Project.RunDotNetPublishAsync(); Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult)); // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release // The output from publish will go into bin/Release/netcoreapp3.0/publish and won't be affected by calling build // later, while the opposite is not true. using var buildResult = await Project.RunDotNetBuildAsync(); Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult)); // localdb is not installed on the CI machines, so skip it. var shouldVisitFetchData = !useLocalDb; if (usesAuth) { using var migrationsResult = await Project.RunDotNetEfCreateMigrationAsync(template); Assert.True(0 == migrationsResult.ExitCode, ErrorMessages.GetFailedProcessMessage("run EF migrations", Project, migrationsResult)); Project.AssertEmptyMigration(template); if (shouldVisitFetchData) { using var dbUpdateResult = await Project.RunDotNetEfUpdateDatabaseAsync(); Assert.True(0 == dbUpdateResult.ExitCode, ErrorMessages.GetFailedProcessMessage("update database", Project, dbUpdateResult)); } } using (var aspNetProcess = Project.StartBuiltProjectAsync()) { Assert.False( aspNetProcess.Process.HasExited, ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process)); await WarmUpServer(aspNetProcess); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); if (BrowserFixture.IsHostAutomationSupported()) { var (browser, logs) = await BrowserFixture.GetOrCreateBrowserAsync(Output, $"{Project.ProjectName}.build"); aspNetProcess.VisitInBrowser(browser); TestBasicNavigation(visitFetchData: shouldVisitFetchData, usesAuth, browser, logs); } else { BrowserFixture.EnforceSupportedConfigurations(); } } if (usesAuth) { UpdatePublishedSettings(); } using (var aspNetProcess = Project.StartPublishedProjectAsync()) { Assert.False( aspNetProcess.Process.HasExited, ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process)); await WarmUpServer(aspNetProcess); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); if (BrowserFixture.IsHostAutomationSupported()) { var (browser, logs) = await BrowserFixture.GetOrCreateBrowserAsync(Output, $"{Project.ProjectName}.publish"); aspNetProcess.VisitInBrowser(browser); TestBasicNavigation(visitFetchData: shouldVisitFetchData, usesAuth, browser, logs); } else { BrowserFixture.EnforceSupportedConfigurations(); } } } private void ValidatePackageJson(string clientAppSubdirPath) { Assert.True(File.Exists(Path.Combine(clientAppSubdirPath, "package.json")), "Missing a package.json"); var packageJson = JObject.Parse(ReadFile(clientAppSubdirPath, "package.json")); // NPM package names must match ^(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$ var packageName = (string)packageJson["name"]; Regex regex = new Regex("^(?:@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$"); Assert.True(regex.IsMatch(packageName), "package.json name is invalid format"); } private static async Task WarmUpServer(AspNetProcess aspNetProcess) { var attempt = 0; var maxAttempts = 3; do { try { attempt++; var response = await aspNetProcess.SendRequest("/"); if (response.StatusCode == HttpStatusCode.OK) { break; } } catch (OperationCanceledException) { } catch (HttpRequestException ex) when (ex.Message.StartsWith("The SSL connection could not be established")) { } await Task.Delay(TimeSpan.FromSeconds(5 * attempt)); } while (attempt < maxAttempts); } private void UpdatePublishedSettings() { // Hijack here the config file to use the development key during publish. var appSettings = JObject.Parse(File.ReadAllText(Path.Combine(Project.TemplateOutputDir, "appsettings.json"))); var appSettingsDevelopment = JObject.Parse(File.ReadAllText(Path.Combine(Project.TemplateOutputDir, "appsettings.Development.json"))); ((JObject)appSettings["IdentityServer"]).Merge(appSettingsDevelopment["IdentityServer"]); ((JObject)appSettings["IdentityServer"]).Merge(new { IdentityServer = new { Key = new { FilePath = "./tempkey.json" } } }); var testAppSettings = appSettings.ToString(); File.WriteAllText(Path.Combine(Project.TemplatePublishDir, "appsettings.json"), testAppSettings); } private void TestBasicNavigation(bool visitFetchData, bool usesAuth, IWebDriver browser, ILogs logs) { browser.Exists(By.TagName("ul")); // element gets project ID injected into it during template execution browser.Contains(Project.ProjectGuid.Replace(".", "._"), () => browser.Title); // Initially displays the home page browser.Equal("Hello, world!", () => browser.FindElement(By.TagName("h1")).Text); // Can navigate to the counter page browser.FindElement(By.PartialLinkText("Counter")).Click(); browser.Contains("counter", () => browser.Url); browser.Equal("Counter", () => browser.FindElement(By.TagName("h1")).Text); // Clicking the counter button works browser.Equal("0", () => browser.FindElement(By.CssSelector("p>strong")).Text); browser.FindElement(By.CssSelector("p+button")).Click(); browser.Equal("1", () => browser.FindElement(By.CssSelector("p>strong")).Text); if (visitFetchData) { browser.FindElement(By.PartialLinkText("Fetch data")).Click(); if (usesAuth) { // We will be redirected to the identity UI browser.Contains("/Identity/Account/Login", () => browser.Url); browser.FindElement(By.PartialLinkText("Register as a new user")).Click(); var userName = $"{Guid.NewGuid()}@example.com"; var password = $"!Test.Password1$"; browser.Exists(By.Name("Input.Email")); browser.FindElement(By.Name("Input.Email")).SendKeys(userName); browser.FindElement(By.Name("Input.Password")).SendKeys(password); browser.FindElement(By.Name("Input.ConfirmPassword")).SendKeys(password); browser.FindElement(By.Id("registerSubmit")).Click(); } // Can navigate to the 'fetch data' page browser.Contains("fetch-data", () => browser.Url); browser.Equal("Weather forecast", () => browser.FindElement(By.TagName("h1")).Text); // Asynchronously loads and displays the table of weather forecasts browser.Exists(By.CssSelector("table>tbody>tr"), TimeSpan.FromSeconds(10)); browser.Equal(5, () => browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count); } foreach (var logKind in logs.AvailableLogTypes) { var entries = logs.GetLog(logKind); var badEntries = entries.Where(e => new LogLevel[] { LogLevel.Warning, LogLevel.Severe }.Contains(e.Level)); badEntries = badEntries.Where(e => !e.Message.Contains("failed: WebSocket is closed before the connection is established.") && !e.Message.Contains("[WDS] Disconnected!") && !e.Message.Contains("Timed out connecting to Chrome, retrying")); Assert.True(badEntries.Count() == 0, "There were Warnings or Errors from the browser." + Environment.NewLine + string.Join(Environment.NewLine, badEntries)); } } private void AssertFileExists(string basePath, string path, bool shouldExist) { var fullPath = Path.Combine(basePath, 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); } } private string ReadFile(string basePath, string path) { AssertFileExists(basePath, path, shouldExist: true); return File.ReadAllText(Path.Combine(basePath, path)); } } }