diff --git a/1-WebApp-OIDC/1-3-AnyOrgOrPersonal/appsettings.json b/1-WebApp-OIDC/1-3-AnyOrgOrPersonal/appsettings.json index 41b09c5d..f02dafbe 100644 --- a/1-WebApp-OIDC/1-3-AnyOrgOrPersonal/appsettings.json +++ b/1-WebApp-OIDC/1-3-AnyOrgOrPersonal/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "AzureAd": { "Instance": "https://login.microsoftonline.com/", "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]", @@ -15,4 +15,4 @@ } }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/3-WebApp-multi-APIs/appsettings.json b/3-WebApp-multi-APIs/appsettings.json index 919da2d1..24c97a37 100644 --- a/3-WebApp-multi-APIs/appsettings.json +++ b/3-WebApp-multi-APIs/appsettings.json @@ -20,4 +20,4 @@ }, "AllowedHosts": "*", "GraphApiUrl": "https://graph.microsoft.com" -} +} \ No newline at end of file diff --git a/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs b/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs new file mode 100644 index 00000000..e7aeddbd --- /dev/null +++ b/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalTest.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using System.Text; +using System.Threading.Tasks; +using Common; +using Microsoft.Identity.Lab.Api; +using Microsoft.Playwright; +using Xunit; +using Xunit.Abstractions; +using Process = System.Diagnostics.Process; +using TC = Common.TestConstants; + +namespace MultipleApiUiTest +{ + public class AnyOrgOrPersonalTest : IClassFixture + { + private const string SignOutPageUriPath = @"/MicrosoftIdentity/Account/SignedOut"; + private const uint ClientPort = 44321; + private const string TraceFileClassName = "OpenIDConnect"; + private const uint NumProcessRetries = 3; + private const string SampleSlnFileName = "1-3-AnyOrgOrPersonal.sln"; + private readonly LocatorAssertionsToBeVisibleOptions _assertVisibleOptions = new() { Timeout = 25000 }; + private readonly string _sampleAppPath = "1-WebApp-OIDC" + Path.DirectorySeparatorChar + "1-3-AnyOrgOrPersonal" + Path.DirectorySeparatorChar.ToString(); + private readonly string _testAppsettingsPath = "UiTests" + Path.DirectorySeparatorChar + "AnyOrgOrPersonalUiTest" + Path.DirectorySeparatorChar.ToString() + TC.AppSetttingsDotJson; + private readonly string _testAssemblyLocation = typeof(AnyOrgOrPersonalTest).Assembly.Location; + private readonly ITestOutputHelper _output; + + public AnyOrgOrPersonalTest(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [SupportedOSPlatform("windows")] + public async Task ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_LoginLogout() + { + // Setup web app and api environmental variables. + var clientEnvVars = new Dictionary + { + {"ASPNETCORE_ENVIRONMENT", "Development"}, + {TC.KestrelEndpointEnvVar, TC.HttpsStarColon + ClientPort} + }; + + Dictionary? processes = null; + + // Arrange Playwright setup, to see the browser UI set Headless = false. + const string TraceFileName = TraceFileClassName + "_LoginLogout"; + using IPlaywright playwright = await Playwright.CreateAsync(); + IBrowser browser = await playwright.Chromium.LaunchAsync(new() { Headless = false }); + IBrowserContext context = await browser.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true }); + await context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true, Sources = true }); + IPage page = await context.NewPageAsync(); + string uriWithPort = TC.LocalhostUrl + ClientPort; + + try + { + // Build the sample app with correct appsettings file. + UiTestHelpers.BuildSampleWithTestAppsettings(_testAssemblyLocation, _sampleAppPath, _testAppsettingsPath, SampleSlnFileName); + + // Start the web app and api processes. + // The delay before starting client prevents transient devbox issue where the client fails to load the first time after rebuilding + var clientProcessOptions = new ProcessStartOptions(_testAssemblyLocation, _sampleAppPath, TC.s_oidcWebAppExe, clientEnvVars); + + bool areProcessesRunning = UiTestHelpers.StartAndVerifyProcessesAreRunning([clientProcessOptions], out processes, NumProcessRetries); + + if (!areProcessesRunning) + { + _output.WriteLine($"Process not started after {NumProcessRetries} attempts."); + StringBuilder runningProcesses = new StringBuilder(); + foreach (var process in processes) + { +#pragma warning disable CA1305 // Specify IFormatProvider + runningProcesses.AppendLine($"Is {process.Key} running: {UiTestHelpers.ProcessIsAlive(process.Value)}"); +#pragma warning restore CA1305 // Specify IFormatProvider + } + Assert.Fail(TC.WebAppCrashedString + " " + runningProcesses.ToString()); + } + + LabResponse labResponse = await LabUserHelper.GetSpecificUserAsync(TC.OIDCUser); + + // Initial sign in + _output.WriteLine("Starting web app sign-in flow."); + string email = labResponse.User.Upn; + await UiTestHelpers.NavigateToWebApp(uriWithPort, page); + await UiTestHelpers.EnterEmailAsync(page, email, _output); + await UiTestHelpers.EnterPasswordAsync(page, labResponse.User.GetOrFetchPassword(), _output); + await Assertions.Expect(page.GetByText("Integrating Azure AD V2")).ToBeVisibleAsync(_assertVisibleOptions); + await Assertions.Expect(page.GetByText(email)).ToBeVisibleAsync(_assertVisibleOptions); + _output.WriteLine("Web app sign-in flow successful."); + + // Sign out + _output.WriteLine("Starting web app sign-out flow."); + await page.GetByRole(AriaRole.Link, new() { Name = "Sign out" }).ClickAsync(); + await UiTestHelpers.PerformSignOut_MicrosoftIdFlow(page, email, TC.LocalhostUrl + ClientPort + SignOutPageUriPath, _output); + _output.WriteLine("Web app sign out successful."); + } + catch (Exception ex) + { + // Adding guid in case of multiple test runs. This will allow screenshots to be matched to their appropriate test runs. + var guid = Guid.NewGuid().ToString(); + try + { + if (page != null) + { + await page.ScreenshotAsync(new PageScreenshotOptions() { Path = $"ChallengeUser_MicrosoftIdFlow_LocalApp_ValidEmailPasswordCreds_TodoAppFunctionsCorrectlyScreenshotFail{guid}.png", FullPage = true }); + } + } + catch + { + _output.WriteLine("No Screenshot."); + } + + string runningProcesses = UiTestHelpers.GetRunningProcessAsString(processes); + Assert.Fail($"the UI automation failed: {ex} output: {ex.Message}.\n{runningProcesses}\nTest run: {guid}"); + } + finally + { + // Make sure all processes and their children are stopped. + UiTestHelpers.EndProcesses(processes); + + // Stop tracing and export it into a zip archive. + string path = UiTestHelpers.GetTracePath(_testAssemblyLocation, TraceFileName); + await context.Tracing.StopAsync(new() { Path = path }); + _output.WriteLine($"Trace data for {TraceFileName} recorded to {path}."); + + // Close the browser and stop Playwright. + await browser.CloseAsync(); + playwright.Dispose(); + } + } + + } +} \ No newline at end of file diff --git a/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalUiTest.csproj b/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalUiTest.csproj new file mode 100644 index 00000000..3ecc58f1 --- /dev/null +++ b/UiTests/AnyOrgOrPersonalUiTest/AnyOrgOrPersonalUiTest.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + false + enable + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/UiTests/AnyOrgOrPersonalUiTest/appsettings.json b/UiTests/AnyOrgOrPersonalUiTest/appsettings.json new file mode 100644 index 00000000..b7775c52 --- /dev/null +++ b/UiTests/AnyOrgOrPersonalUiTest/appsettings.json @@ -0,0 +1,18 @@ +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "Domain": "msidlab3.onmicrosoft.com", + "TenantId": "8e44f19d-bbab-4a82-b76b-4cd0a6fbc97a", + "ClientId": "d9cde0be-ad97-41e6-855e-2f85136671c1", + "CallbackPath": "/signin-oidc", + "SignedOutCallbackPath": "/signout-callback-oidc" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/UiTests/Common/Common.csproj b/UiTests/Common/Common.csproj new file mode 100644 index 00000000..7fefe64d --- /dev/null +++ b/UiTests/Common/Common.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/UiTests/Common/TestConstants.cs b/UiTests/Common/TestConstants.cs new file mode 100644 index 00000000..426e4fec --- /dev/null +++ b/UiTests/Common/TestConstants.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Common +{ + public static class TestConstants + { + public const string AppSetttingsDotJson = "appsettings.json"; + public const string ClientFilePrefix = "client_"; + public const string EmailText = "Email"; + public const string Headless = "headless"; + public const string HeaderText = "Header"; + public const string HttpStarColon = "http://*:"; + public const string HttpsStarColon = "https://*:"; + public const string KestrelEndpointEnvVar = "Kestrel:Endpoints:Http:Url"; + public const string LocalhostUrl = @"https://localhost:"; + public const string OIDCUser = "fIDLAB@MSIDLAB3.com"; + public const string PasswordText = "Password"; + public const string ServerFilePrefix = "server_"; + public const string TodoTitle1 = "Testing create todo item"; + public const string TodoTitle2 = "Testing edit todo item"; + public const string WebAppCrashedString = $"The web app process has exited prematurely."; + + public static readonly string s_oidcWebAppExe = Path.DirectorySeparatorChar.ToString() + "WebApp-OpenIDConnect-DotNet.exe"; + public static readonly string s_oidcWebAppPath = Path.DirectorySeparatorChar.ToString() + "WebApp-OpenIDConnect"; + public static readonly string s_todoListClientExe = Path.DirectorySeparatorChar.ToString() + "TodoListClient.exe"; + public static readonly string s_todoListClientPath = Path.DirectorySeparatorChar.ToString() + "Client"; + public static readonly string s_todoListServiceExe = Path.DirectorySeparatorChar.ToString() + "TodoListService.exe"; + public static readonly string s_todoListServicePath = Path.DirectorySeparatorChar.ToString() + "TodoListService"; + } +} diff --git a/UiTests/Common/UiTestHelpers.cs b/UiTests/Common/UiTestHelpers.cs new file mode 100644 index 00000000..9ceb365e --- /dev/null +++ b/UiTests/Common/UiTestHelpers.cs @@ -0,0 +1,600 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Playwright; +using System.Diagnostics; +using System.Management; +using System.Runtime.Versioning; +using System.Text; +using Xunit.Abstractions; + +namespace Common +{ + public static class UiTestHelpers + { + /// + /// Navigates to a web page with retry logic to more reliably connect in case a web app needs more startup time. + /// + /// The uri to navigate to + /// A page in a playwright browser + public static async Task NavigateToWebApp(string uri, IPage page) + { + uint InitialConnectionRetryCount = 5; + while (InitialConnectionRetryCount > 0) + { + try + { + await page.GotoAsync(uri); + break; + } + catch (PlaywrightException) + { + await Task.Delay(1000); + InitialConnectionRetryCount--; + if (InitialConnectionRetryCount == 0) + { throw; } + } + } + } + + /// + /// Login flow for the first time in a given browsing session. + /// + /// Playwright Page object the web app is accessed from + /// email of the user to sign in + /// password for sign in + /// Used to communicate output to the test's Standard Output + /// Whether to select "stay signed in" on login + public static async Task FirstLogin_MicrosoftIdFlow_ValidEmailPassword(IPage page, string email, string password, ITestOutputHelper? output = null, bool staySignedIn = false) + { + string staySignedInText = staySignedIn ? "Yes" : "No"; + await EnterEmailAsync(page, email, output); + await EnterPasswordAsync(page, password, output); + await StaySignedIn_MicrosoftIdFlow(page, staySignedInText, output); + } + + /// + /// Login flow for anytime after the first time in a given browsing session. + /// + /// Playwright Page object the web app is accessed from + /// email of the user to sign in + /// password for sign in + /// Used to communicate output to the test's Standard Output + /// Whether to select "stay signed in" on login + public static async Task SuccessiveLogin_MicrosoftIdFlow_ValidEmailPassword(IPage page, string email, string password, ITestOutputHelper? output = null, bool staySignedIn = false) + { + string staySignedInText = staySignedIn ? "Yes" : "No"; + + WriteLine(output, $"Logging in again in this browsing session... selecting user via email: {email}."); + await SelectKnownAccountByEmail_MicrosoftIdFlow(page, email); + await EnterPasswordAsync(page, password, output); + await StaySignedIn_MicrosoftIdFlow(page, staySignedInText, output); + } + + /// + /// Enters the email of the user to sign in. + /// + /// Playwright Page object the login is occurring on + /// The email to use for the login + /// Used to communicate output to the test's Standard Output + public static async Task EnterEmailAsync(IPage page, string email, ITestOutputHelper? output = null) + { + WriteLine(output, $"Logging in ... Entering and submitting user name: {email}."); + ILocator emailInputLocator = page.GetByPlaceholder(TestConstants.EmailText); + await FillEntryBox(emailInputLocator, email); + } + + /// + /// Signs the current user out of the web app. + /// + /// Playwright Page object the web app is accessed from + /// email of the user to sign out + /// The url for the page arrived at once successfully signed out + public static async Task PerformSignOut_MicrosoftIdFlow(IPage page, string email, string signOutPageUrl, ITestOutputHelper? output = null) + { + WriteLine(output, "Signing out ..."); + await SelectKnownAccountByEmail_MicrosoftIdFlow(page, email.ToLowerInvariant()); + await page.WaitForURLAsync(signOutPageUrl); + WriteLine(output, "Sign out page successfully reached."); + } + + /// + /// In the Microsoft Identity flow, the user is at certain stages presented with a list of accounts known in + /// the current browsing session to choose from. This method selects the account using the user's email. + /// + /// page for the playwright browser + /// user email address to select + private static async Task SelectKnownAccountByEmail_MicrosoftIdFlow(IPage page, string email) + { + await page.Locator($"[data-test-id=\"{email}\"]").ClickAsync(); + } + + /// + /// The set of steps to take when given a password to enter and submit when logging in via Microsoft. + /// + /// The browser page instance. + /// The password for the account you're logging into. + /// "Yes" or "No" to stay signed in for the given browsing session. + /// The writer for output to the test's console. + public static async Task EnterPasswordAsync(IPage page, string password, ITestOutputHelper? output = null) + { + // If using an account that has other non-password validation options, the below code should be uncommented + /* WriteLine(output, "Selecting \"Password\" as authentication method"); + await page.GetByRole(AriaRole.Button, new() { Name = TestConstants.PasswordText }).ClickAsync();*/ + + WriteLine(output, "Logging in ... entering and submitting password."); + ILocator passwordInputLocator = page.GetByPlaceholder(TestConstants.PasswordText); + await FillEntryBox(passwordInputLocator, password); + } + + private static async Task StaySignedIn_MicrosoftIdFlow(IPage page, string staySignedInText, ITestOutputHelper? output = null) + { + WriteLine(output, $"Logging in ... Clicking {staySignedInText} on whether the browser should stay signed in."); + await page.GetByRole(AriaRole.Button, new() { Name = staySignedInText }).ClickAsync(); + } + + private static async Task FillEntryBox(ILocator entryBox, string entryText) + { + await entryBox.ClickAsync(); + await entryBox.FillAsync(entryText); + await entryBox.PressAsync("Enter"); + } + + private static void WriteLine(ITestOutputHelper? output, string message) + { + if (output != null) + { + output.WriteLine(message); + } + else + { + Trace.WriteLine(message); + } + } + + /// + /// This starts the recording of playwright trace files. The corresponding EndAndWritePlaywrightTrace method will also need to be used. + /// This is not used anywhere by default and will need to be added to the code if desired. + /// + /// The page object whose context the trace will record. + public static async Task StartPlaywrightTrace(IPage page) + { + await page.Context.Tracing.StartAsync(new() + { + Screenshots = true, + Snapshots = true, + Sources = true + }); + } + + /// + /// Starts a process from an executable, sets its working directory, and redirects its output to the test's output. + /// + /// The path to the test's directory. + /// The path to the processes directory. + /// The name of the executable that launches the process. + /// The port for the process to listen on. + /// If the launch URL is http or https. Default is https. + /// The started process. + public static Process StartProcessLocally(string testAssemblyLocation, string appLocation, string executableName, Dictionary? environmentVariables = null) + { + string applicationWorkingDirectory = GetApplicationWorkingDirectory(testAssemblyLocation, appLocation); + ProcessStartInfo processStartInfo = new ProcessStartInfo(applicationWorkingDirectory + executableName) + { + WorkingDirectory = applicationWorkingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (environmentVariables != null) + { + foreach (var kvp in environmentVariables) + { + processStartInfo.EnvironmentVariables[kvp.Key] = kvp.Value; + } + } + + Process? process = Process.Start(processStartInfo); + + if (process == null) + { + throw new Exception($"Could not start process {executableName}"); + } + else + { + return process; + } + } + + /// + /// Builds the path to the process's directory + /// + /// The path to the test's directory + /// The path to the processes directory + /// The path to the directory for the given app + private static string GetApplicationWorkingDirectory(string testAssemblyLocation, string appLocation) + { + string testedAppLocation = Path.GetDirectoryName(testAssemblyLocation)!; + // e.g. microsoft-identity-web\tests\E2E Tests\WebAppUiTests\bin\Debug\net6.0 + string[] segments = testedAppLocation.Split(Path.DirectorySeparatorChar); + int numberSegments = segments.Length; + int startLastSegments = numberSegments - 3; + int endFirstSegments = startLastSegments - 2; + return Path.Combine( + Path.Combine(segments.Take(endFirstSegments).ToArray()), + appLocation, + Path.Combine(segments.Skip(startLastSegments).ToArray()) + ); + } + + /// + /// Builds the path to the process's directory + /// + /// The path to the test's directory + /// The path to the processes directory + /// The path to the directory for the given app + private static string GetAppsettingsDirectory(string testAssemblyLocation, string appLocation) + { + string testedAppLocation = Path.GetDirectoryName(testAssemblyLocation)!; + // e.g. microsoft-identity-web\tests\E2E Tests\WebAppUiTests\bin\Debug\net6.0 + string[] segments = testedAppLocation.Split(Path.DirectorySeparatorChar); + int numberSegments = segments.Length; + int startLastSegments = numberSegments - 3; + int endFirstSegments = startLastSegments - 2; + return Path.Combine( + Path.Combine(segments.Take(endFirstSegments).ToArray()), + appLocation + ); + } + + /// + /// Creates absolute path for Playwright trace file + /// + /// The path the test is being run from + /// The name for the zip file containing the trace + /// An absolute path to a Playwright Trace zip folder + public static string GetTracePath(string testAssemblyLocation, string traceName) + { + const string traceParentFolder = "E2E Tests"; + const string traceFolder = "PlaywrightTraces"; + const string zipExtension = ".zip"; + const int netVersionNumberLength = 3; + + int parentFolderIndex = testAssemblyLocation.IndexOf(traceParentFolder, StringComparison.InvariantCulture); + string substring = testAssemblyLocation[..(parentFolderIndex + traceParentFolder.Length)]; + string netVersion = "_net" + Environment.Version.ToString()[..netVersionNumberLength]; + + // e.g. [absolute path to repo root]\tests\E2E Tests\PlaywrightTraces\[traceName]_net[versionNum].zip + return Path.Combine( + substring, + traceFolder, + traceName + netVersion + zipExtension + ); + } + + /// + /// Goes through all processes and ends them and any child processes they spawned + /// + /// + public static void EndProcesses(Dictionary? processes) + { + Queue processQueue = new(); + if (processes != null) + { + foreach (var process in processes) + { + processQueue.Enqueue(process.Value); + } + } + KillProcessTrees(processQueue); + } + + /// + /// Kills the processes in the queue and all of their children + /// + /// queue of parent processes + private static void KillProcessTrees(Queue processQueue) + { +#if WINDOWS + Process currentProcess; + while (processQueue.Count > 0) + { + currentProcess = processQueue.Dequeue(); + if (currentProcess == null) + continue; + + foreach (Process child in GetChildProcesses(currentProcess)) + { + processQueue.Enqueue(child); + } + currentProcess.Kill(); + currentProcess.Close(); + } +#else + while (processQueue.Count > 0) + { + Process p = processQueue.Dequeue(); + p.Kill(); + p.WaitForExit(); + } +#endif + } + + /// + /// Gets the child processes of a process on Windows + /// + /// The parent process + /// A list of child processes + [SupportedOSPlatform("windows")] + public static IList GetChildProcesses(this Process process) + { + ManagementObjectSearcher processSearch = new ManagementObjectSearcher($"Select * From Win32_Process Where ParentProcessID={process.Id}"); + IList processList = processSearch.Get() + .Cast() + .Select(mo => + Process.GetProcessById(Convert.ToInt32(mo["ProcessID"], System.Globalization.CultureInfo.InvariantCulture))) + .ToList(); + processSearch.Dispose(); + return processList; + } + + /// + /// Checks if all processes in a list are alive + /// + /// List of processes to check + /// True if all are alive else false + public static bool ProcessesAreAlive(List processes) + { + return processes.All(ProcessIsAlive); + } + + /// + /// Checks if a process is alive + /// + /// Process to check + /// True if alive false if not + public static bool ProcessIsAlive(Process process) + { + return !process.HasExited; + } + + /// + /// Installs the chromium browser for Playwright enabling it to run even if no browser otherwise exists in the test environment + /// + /// Thrown if playwright is unable to install the browsers + public static void InstallPlaywrightBrowser() + { + var exitCode = Microsoft.Playwright.Program.Main(new[] { "install", "chromium" }); + if (exitCode != 0) + { + throw new Exception($"Playwright exited with code {exitCode}"); + } + } + + /// + /// Requests a secret from keyvault using the default azure credentials + /// + /// The URI including path to the secret directory in keyvault + /// The name of the secret + /// The value of the secret from key vault + /// Throws if no secret name is provided + internal static async Task GetValueFromKeyvaultWitDefaultCreds(Uri keyvaultUri, string keyvaultSecretName, TokenCredential creds) + { + if (string.IsNullOrEmpty(keyvaultSecretName)) + { + throw new ArgumentNullException(nameof(keyvaultSecretName)); + } + SecretClient client = new(keyvaultUri, creds); + return (await client.GetSecretAsync(keyvaultSecretName)).Value.Value; + } + + /// + /// Starts all processes in the given list and verifies that they are running + /// + /// The startup options for each process to be started + /// A dictionary to hold the process objects once started + /// The number of times to retry starting a process + /// A boolean to say whether all the processes were able to start up successfully + public static bool StartAndVerifyProcessesAreRunning(List processDataEntries, out Dictionary processes, uint numRetries) + { + processes = new Dictionary(); + + // Start Processes + foreach (ProcessStartOptions processDataEntry in processDataEntries) + { + var process = UiTestHelpers.StartProcessLocally( + processDataEntry.TestAssemblyLocation, + processDataEntry.AppLocation, + processDataEntry.ExecutableName, + processDataEntry.EnvironmentVariables); + + processes.Add(processDataEntry.ExecutableName, process); + + // Gives the current process time to start up before the next process is run + Thread.Sleep(2000); + } + + // Verify that processes are running + for (int i = 0; i < numRetries; i++) + { + if (!UiTestHelpers.ProcessesAreAlive(processes.Values.ToList())) { RestartProcesses(processes, processDataEntries); } + else { break; } + } + + if (!UiTestHelpers.ProcessesAreAlive(processes.Values.ToList())) + { + return false; + } + + return true; + } + + private static void RestartProcesses(Dictionary processes, List processDataEntries) + { + // attempt to restart failed processes + foreach (KeyValuePair processEntry in processes) + { + if (!ProcessIsAlive(processEntry.Value)) + { + var processDataEntry = processDataEntries.Where(x => x.ExecutableName == processEntry.Key).Single(); + var process = StartProcessLocally( + processDataEntry.TestAssemblyLocation, + processDataEntry.AppLocation, + processDataEntry.ExecutableName, + processDataEntry.EnvironmentVariables); + Thread.Sleep(5000); + + // Update process in collection + processes[processEntry.Key] = process; + } + } + } + + /// + /// Returns a string representation of the running processes + /// + /// Dict of running processes + /// A string of all processes from the give dict + public static string GetRunningProcessAsString(Dictionary? processes) + { + StringBuilder runningProcesses = new StringBuilder(); + if (processes != null) + { + foreach (var process in processes) + { +#pragma warning disable CA1305 // Specify IFormatProvider + runningProcesses.AppendLine($"Is {process.Key} running: {UiTestHelpers.ProcessIsAlive(process.Value)}"); +#pragma warning restore CA1305 // Specify IFormatProvider + } + } + return runningProcesses.ToString(); + } + + /// + /// Takes two paths to existing files and swaps their names and locations effectively swapping the contents of the files. + /// + /// The path of the first file to swap + /// The path of the file to swap it with + private static void SwapFiles(string path1, string path2) + { + // Read the contents of both files + string file1Contents = File.ReadAllText(path1); + string file2Contents = File.ReadAllText(path2); + + // Write the contents of file2 to file1 + File.WriteAllText(path1, file2Contents); + try + { + // Write the contents of file1 to file2 + File.WriteAllText(path2, file1Contents); + } + catch (Exception) + { + // If the second write fails, revert the first write + File.WriteAllText(path1, file1Contents); + throw; + } + + Console.WriteLine("File contents swapped successfully."); + } + + /// + /// Builds the solution at the given path. + /// + /// Absolute path to the sln file to be built + private static void BuildSolution(string solutionPath) + { + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"build {solutionPath}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (Process process = new Process()) + { + process.StartInfo = startInfo; + process.OutputDataReceived += (sender, e) => Console.WriteLine(e.Data); + process.ErrorDataReceived += (sender, e) => Console.WriteLine(e.Data); + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + } + + Console.WriteLine("Solution rebuild initiated."); + } + + /// + /// Replaces existing appsettings.json file with a test appsettings file, builds the solution, and then swaps the files back. + /// + /// Absolute path to the current test's working directory + /// Relative path to the sample app to build starting at the repo's root, does not include appsettings filename + /// Relative path to the test appsettings file starting at the repo's root, includes appsettings filename + /// Filename for the sln file to build + public static void BuildSampleWithTestAppsettings( + string testAssemblyLocation, + string sampleRelPath, + string testAppsettingsRelPath, + string solutionFileName + ) + { + string appsettingsDirectory = GetAppsettingsDirectory(testAssemblyLocation, sampleRelPath); + string appsettingsAbsPath = Path.Combine(appsettingsDirectory, TestConstants.AppSetttingsDotJson); + string testAppsettingsAbsPath = GetAppsettingsDirectory(testAssemblyLocation, testAppsettingsRelPath); + + SwapFiles(appsettingsAbsPath, testAppsettingsAbsPath); + + try { BuildSolution(appsettingsDirectory + solutionFileName); } + catch (Exception) { throw; } + finally { SwapFiles(appsettingsAbsPath, testAppsettingsAbsPath); } + } + } + + /// + /// Fixture class that installs Playwright browser once per xunit test class that implements it + /// + public class InstallPlaywrightBrowserFixture : IDisposable + { + public InstallPlaywrightBrowserFixture() + { + UiTestHelpers.InstallPlaywrightBrowser(); + } + public void Dispose() + { + } + } + + /// + /// A POCO class to hold the options for starting a process + /// + public class ProcessStartOptions + { + public string TestAssemblyLocation { get; } + + public string AppLocation { get; } + + public string ExecutableName { get; } + + public Dictionary? EnvironmentVariables { get; } + + public ProcessStartOptions( + string testAssemblyLocation, + string appLocation, + string executableName, + Dictionary? environmentVariables = null) + { + TestAssemblyLocation = testAssemblyLocation; + AppLocation = appLocation; + ExecutableName = executableName; + EnvironmentVariables = environmentVariables; + } + } +} + diff --git a/UiTests/Directory.Build.props b/UiTests/Directory.Build.props new file mode 100644 index 00000000..98953e18 --- /dev/null +++ b/UiTests/Directory.Build.props @@ -0,0 +1,24 @@ + + + + false + net8.0 + false + false + + + + 6.0.2 + 8.0.8 + 1.0.2 + 17.11.1 + 1.47.0 + 8.0.0 + 8.0.4 + 2.9.1 + 2.9.1 + 2.8.2 + 2.9.1 + + + diff --git a/UiTests/UiTests.sln b/UiTests/UiTests.sln new file mode 100644 index 00000000..f0febbc6 --- /dev/null +++ b/UiTests/UiTests.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35303.130 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnyOrgOrPersonalUiTest", "AnyOrgOrPersonalUiTest\AnyOrgOrPersonalUiTest.csproj", "{2B42751A-8650-4DE4-9B46-B01C21825EB1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "Common\Common.csproj", "{3074B729-52E8-408E-8BBC-815FE9217385}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6A8B8F57-DBC6-43E2-84E7-16D24E54157B}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2B42751A-8650-4DE4-9B46-B01C21825EB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B42751A-8650-4DE4-9B46-B01C21825EB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B42751A-8650-4DE4-9B46-B01C21825EB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B42751A-8650-4DE4-9B46-B01C21825EB1}.Release|Any CPU.Build.0 = Release|Any CPU + {3074B729-52E8-408E-8BBC-815FE9217385}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3074B729-52E8-408E-8BBC-815FE9217385}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3074B729-52E8-408E-8BBC-815FE9217385}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3074B729-52E8-408E-8BBC-815FE9217385}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F7161FC1-9BC2-4CE4-B59C-504328CA6C7F} + EndGlobalSection +EndGlobal