diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index 4009f16938a8..ef20ea712b10 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -441,9 +441,9 @@
-
+
https://github.com/dotnet/source-build-externals
- e4ddbedd151b969514f2c5b756616707c31e0bfb
+ 4e60131607fd144eb86fe4487f1a37da940ca990
diff --git a/sdk.sln b/sdk.sln
index c57d23959f8c..1e3a6f52e474 100644
--- a/sdk.sln
+++ b/sdk.sln
@@ -507,6 +507,8 @@ Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Microsoft.WebTools.AspireSe
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Net.Sdk.Compilers.Toolset", "src\Microsoft.Net.Sdk.Compilers.Toolset\Microsoft.Net.Sdk.Compilers.Toolset.csproj", "{FA579C03-2EB4-4D47-88EE-BFF339E96FAF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.WebTools.AspireService.Tests", "test\Microsoft.WebTools.AspireService.Tests\Microsoft.WebTools.AspireService.Tests.csproj", "{1F0B4B3C-DC88-4740-B04F-1707102E9930}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -965,6 +967,10 @@ Global
{FA579C03-2EB4-4D47-88EE-BFF339E96FAF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FA579C03-2EB4-4D47-88EE-BFF339E96FAF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FA579C03-2EB4-4D47-88EE-BFF339E96FAF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1F0B4B3C-DC88-4740-B04F-1707102E9930}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1143,6 +1149,7 @@ Global
{19014C60-F87C-4CC7-AC0F-C41B6126EBCE} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91}
{94C8526E-DCC2-442F-9868-3DD0BA2688BE} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91}
{FA579C03-2EB4-4D47-88EE-BFF339E96FAF} = {22AB674F-ED91-4FBC-BFEE-8A1E82F9F05E}
+ {1F0B4B3C-DC88-4740-B04F-1707102E9930} = {580D1AE7-AA8F-4912-8B76-105594E00B3B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6}
@@ -1150,6 +1157,7 @@ Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{03c5a84a-982b-4f38-ac73-ab832c645c4a}*SharedItemsImports = 5
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{0a3c9afd-f6e6-4a5d-83fb-93bf66732696}*SharedItemsImports = 5
+ src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{1f0b4b3c-dc88-4740-b04f-1707102e9930}*SharedItemsImports = 5
src\BuiltInTools\AspireService\Microsoft.WebTools.AspireService.projitems*{94c8526e-dcc2-442f-9868-3dd0ba2688be}*SharedItemsImports = 13
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{9d36039f-d0a1-462f-85b4-81763c6b02cb}*SharedItemsImports = 13
src\Compatibility\ApiCompat\Microsoft.DotNet.ApiCompat.Shared\Microsoft.DotNet.ApiCompat.Shared.projitems*{a9103b98-d888-4260-8a05-fa36f640698a}*SharedItemsImports = 5
diff --git a/src/BuiltInTools/AspireService/AspireServerService.cs b/src/BuiltInTools/AspireService/AspireServerService.cs
index d777ce951154..3b620131f73f 100644
--- a/src/BuiltInTools/AspireService/AspireServerService.cs
+++ b/src/BuiltInTools/AspireService/AspireServerService.cs
@@ -1,18 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
-using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
-using System.Threading;
-using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
diff --git a/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs b/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs
index 189a88c48ed5..a0b0f7766d48 100644
--- a/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs
+++ b/src/BuiltInTools/AspireService/Helpers/CertGenerator.cs
@@ -1,7 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
@@ -35,7 +34,11 @@ public static X509Certificate2 GenerateCert()
// The file will be automatically generated by the following call and disposed when the returned cert is disposed.
using (cert)
{
+#if NET9_0_OR_GREATER
+ return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), password: null, X509KeyStorageFlags.UserKeySet);
+#else
return new X509Certificate2(cert.Export(X509ContentType.Pfx), "", X509KeyStorageFlags.UserKeySet);
+#endif
}
}
else
diff --git a/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs b/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs
new file mode 100644
index 000000000000..69d06b8c0479
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs
@@ -0,0 +1,532 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.WebSockets;
+using System.Security.Cryptography.X509Certificates;
+using System.Text.Json;
+using Microsoft.WebTools.AspireServer.Helpers;
+using Microsoft.WebTools.AspireServer.Models;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+public class AspireServerServiceTests
+{
+ private const string Project1Path = @"c:\test\Projects\project1.csproj";
+ private const int ProcessId = 34213;
+ private const string DcpId = "myid";
+ private const string SpecificProfileName = "SpecificProfile";
+ private const string VersionedSessionUrl = $"{RunSessionRequest.Url}?{RunSessionRequest.VersionQuery}={RunSessionRequest.OurProtocolVersion}";
+
+ private static readonly TestRunSessionRequest Project1SessionRequest = new TestRunSessionRequest(Project1Path, debugging: false, launchProfile: null, disableLaunchProfile: false)
+ {
+ args = new List { "--project1Arg" },
+ env = new List { new EnvVar { Name = "var1", Value = "value1" } }
+ };
+
+ [Fact]
+ public async Task SessionStarted_Test()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ // Start listening
+ TaskCompletionSource connected = new();
+
+ TaskCompletionSource notificationTask = new();
+ _ = listenForSessionUpdatesAsync(server, connected, (sn) =>
+ {
+ notificationTask.SetResult((SessionChangeNotification)sn);
+ });
+
+ await connected.Task;
+
+ await server.SessionStartedAsync(DcpId,"1", ProcessId, CancellationToken.None);
+
+ var result = await notificationTask.Task;
+
+ Assert.Equal(ProcessId, result.PID);
+ Assert.Equal("1", result.SessionId);
+
+ await server.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task SessionEndedAsync_Test()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ // Start listening
+ TaskCompletionSource connected = new();
+ TaskCompletionSource sessionEndNotificationTask = new();
+ _ = listenForSessionUpdatesAsync(server, connected, (sn) =>
+ {
+ if (sn.NotificationType == NotificationType.SessionTerminated)
+ {
+ sessionEndNotificationTask.SetResult((SessionChangeNotification)sn);
+ }
+ });
+
+ await connected.Task;
+
+ await server.SessionEndedAsync(DcpId, "1", ProcessId, 130, CancellationToken.None);
+
+ var result = await sessionEndNotificationTask.Task;
+ Assert.Equal(ProcessId, result.PID);
+ Assert.Equal("1", result.SessionId);
+ Assert.Equal(130, result.ExitCode);
+
+ await server.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task LaunchProject_Success()
+ {
+ var mocks = new Mocks();
+
+ mocks.GetOrCreate()
+ .ImplementStartProjectAsync(DcpId, "2");
+
+ var server = await GetAspireServer(mocks);
+ var tokens = await server.GetServerVariablesAsync();
+
+ using HttpClient client = GetHttpClient(tokens);
+
+ HttpResponseMessage response;
+ response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ Assert.Equal($"{client.BaseAddress}run_session/2", response.Headers.Location.AbsoluteUri);
+
+ await server.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task LaunchProject_Success_ThenStopProcessRequest()
+ {
+ var mocks = new Mocks();
+
+ mocks.GetOrCreate()
+ .ImplementStartProjectAsync(DcpId, "2")
+ .ImplementStopSessionAsync(DcpId, "2", exists: true)
+ .ImplementStopSessionAsync(DcpId, "3", exists: false);
+
+ var server = await GetAspireServer(mocks);
+ var tokens = await server.GetServerVariablesAsync();
+
+ using HttpClient client = GetHttpClient(tokens);
+
+ var response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest);
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+
+ // Now send a stop session
+ response = await client.DeleteAsync(RunSessionRequest.Url + "/2");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ // Validate NoContent response if session not found
+ response = await client.DeleteAsync(RunSessionRequest.Url + "/3");
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+
+ await server.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task LaunchProject_FailedToLaunchProject()
+ {
+ var mocks = new Mocks();
+
+ mocks.GetOrCreate()
+ .ImplementStartProjectAsync(DcpId, "2", new Exception("Launch project failed"));
+
+ var server = await GetAspireServer(mocks);
+
+ var tokens = await server.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+
+ var response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest);
+
+ Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
+ Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());
+ Assert.Equal("{\"error\":{\"message\":\"Launch project failed\"}}", await response.Content.ReadAsStringAsync());
+
+ await server.DisposeAsync();
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task LaunchProject_FailNoBearerToken()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ var tokens = await server.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "badToken");
+
+ var response = await client.PutAsJsonAsync(VersionedSessionUrl, Project1SessionRequest);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+
+ await server.DisposeAsync();
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task LaunchProject_FailWrongUrl()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ var tokens = await server.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+
+ var response = await client.PutAsJsonAsync("/run_badurl", Project1SessionRequest);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+
+ await server.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task LaunchProject_NotAPUTRequest()
+ {
+ var mocks = new Mocks();
+
+ var aspireServer = await GetAspireServer(mocks);
+
+ var tokens = await aspireServer.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+
+ var response = await client.PostAsJsonAsync(VersionedSessionUrl, Project1SessionRequest);
+
+ Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
+
+ await aspireServer.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task StopSession_FailNoBearerToken()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ var tokens = await server.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "badToken");
+
+ var response = await client.DeleteAsync(RunSessionRequest.Url + "/2");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+
+ await server.DisposeAsync();
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task Info_Success()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ var tokens = await server.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+
+ var response = await client.GetAsync(InfoResponse.Url);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ await server.DisposeAsync();
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task Info_FailNoBearerToken()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks);
+
+ var tokens = await server.GetServerVariablesAsync();
+ using HttpClient client = GetHttpClient(tokens);
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "badToken");
+
+ var response = await client.GetAsync(InfoResponse.Url);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+
+ await server.DisposeAsync();
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task SendLogMessageAsync_Test()
+ {
+ var mocks = new Mocks();
+
+ var aspireServer = await GetAspireServer(mocks);
+
+
+ // Start listening
+ TaskCompletionSource connected = new();
+ TaskCompletionSource notificationTask = new();
+ _ = listenForSessionUpdatesAsync(aspireServer, connected, (sn) =>
+ {
+ notificationTask.SetResult((SessionLogsNotification)sn);
+ });
+
+ await connected.Task;
+
+ await aspireServer.SendLogMessageAsync(DcpId, "1", isStdErr: false, "My Message", CancellationToken.None);
+
+ var result = await notificationTask.Task;
+
+ Assert.Equal("My Message", result.LogMessage);
+ Assert.False(result.IsStdErr);
+ await aspireServer.DisposeAsync();
+
+ mocks.Verify();
+ }
+
+ [Fact]
+ public async Task GetEnvironmentForOrchestrator_Tests()
+ {
+ var mocks = new Mocks();
+
+ var server = await GetAspireServer(mocks, waitForListening: false);
+
+ // First time should create a key
+ var envVars = await server.GetServerConnectionEnvironmentAsync(CancellationToken.None);
+
+ Assert.Equal(3, envVars.Count);
+ var token = envVars[1];
+ Assert.NotNull(token.Value);
+
+ // Should return the same
+ envVars = await server.GetServerConnectionEnvironmentAsync(CancellationToken.None);
+ Assert.Equal(token, envVars[1]);
+
+ mocks.Verify();
+ }
+
+ private async Task listenForSessionUpdatesAsync(AspireServerService aspireServer, TaskCompletionSource connected, Action callback)
+ {
+ var tokens = await aspireServer.GetServerVariablesAsync();
+ using var httpClient = GetHttpClient(tokens);
+
+ using var ws = new ClientWebSocket();
+ ws.Options.SetRequestHeader("Authorization", $"Bearer {tokens.bearerToken}");
+ try
+ {
+ await ws.ConnectAsync(new Uri($"wss://{tokens.serverAddress}{RunSessionRequest.Url}{SessionNotificationBase.Url}"), httpClient, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ Assert.Fail("Could not connect to session update endpoint: " + ex.ToString());
+ connected.SetResult(false);
+ return;
+ }
+
+ connected.SetResult(true);
+
+ while (ws.State == WebSocketState.Open)
+ {
+ try
+ {
+ var (message, messageType) = await GetSocketMsgAsync(ws);
+
+ if (messageType == WebSocketMessageType.Close)
+ {
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
+ return;
+ }
+ else
+ {
+ var notificationBase = JsonSerializer.Deserialize(message, AspireServerService.JsonSerializerOptions);
+ if (notificationBase is null)
+ {
+ Console.WriteLine("Unexpected null SessionNotificationBase message");
+ }
+ else if (notificationBase.NotificationType == NotificationType.ProcessRestarted || notificationBase.NotificationType == NotificationType.SessionTerminated)
+ {
+ var scn = JsonSerializer.Deserialize(message, AspireServerService.JsonSerializerOptions);
+ if (scn is null)
+ {
+ Assert.Fail("Unexpected null SessionChangeNotification message");
+ }
+ else
+ {
+ callback.Invoke(scn);
+ }
+ }
+ else if (notificationBase.NotificationType == NotificationType.ServiceLogs)
+ {
+ var sessionLogs = JsonSerializer.Deserialize(message, AspireServerService.JsonSerializerOptions);
+ if (sessionLogs is null)
+ {
+ Assert.Fail("Unexpected null SessionLogsNotification message");
+ }
+ else
+ {
+ callback.Invoke(sessionLogs);
+ }
+ }
+ }
+ }
+ catch
+ {
+ // This is expected if the connection is closed
+ return;
+ }
+ }
+ }
+
+ private static HttpClient GetHttpClient((string serverAddress, string bearerToken, string certToken) tokens)
+ {
+ HttpClient client;
+ var serverCert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(tokens.certToken));
+ var clientHandler = new HttpClientHandler()
+ {
+ ClientCertificateOptions = ClientCertificateOption.Manual,
+ SslProtocols = System.Security.Authentication.SslProtocols.None,
+ ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
+ {
+ return cert?.Thumbprint == serverCert.Thumbprint;
+ }
+ };
+
+ client = new HttpClient(clientHandler);
+ client.BaseAddress = new Uri($"https://{tokens.serverAddress}");
+
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.bearerToken);
+ client.DefaultRequestHeaders.Add(HttpContextExtensions.DCPInstanceIDHeader, DcpId);
+
+ return client;
+ }
+
+ private async Task<(string, WebSocketMessageType)> GetSocketMsgAsync(ClientWebSocket client)
+ {
+ var rcvBuffer = new ArraySegment(new byte[2048]);
+ WebSocketReceiveResult rcvResult = await client.ReceiveAsync(rcvBuffer, CancellationToken.None);
+ if (rcvResult.MessageType == WebSocketMessageType.Text)
+ {
+ byte[] msgBytes = rcvBuffer.Skip(rcvBuffer.Offset).Take(rcvResult.Count).ToArray();
+ return (Encoding.UTF8.GetString(msgBytes), rcvResult.MessageType);
+ }
+
+ return (null, rcvResult.MessageType);
+ }
+
+ private async Task GetAspireServer(Mocks mocks, bool waitForListening = true)
+ {
+ var ase = mocks.GetOrCreate();
+
+ var aspireServer = new AspireServerService(ase.Object, displayName: "Test server", Console.WriteLine);
+
+ if (waitForListening)
+ {
+ await aspireServer.WaitForListeningAsync();
+ }
+
+ return aspireServer;
+ }
+
+#pragma warning disable IDE1006 // Naming Styles
+ internal class TestRunSessionRequestP4
+ {
+ public string project_path { get; set; } = string.Empty;
+ public bool debug { get; set; }
+ public List env { get; set; } = new List();
+ public List args { get; set; } = new List();
+ public string launch_profile { get; set; }
+ public bool disable_launch_profile { get; set; }
+ }
+
+ internal class TestRunSessionRequest
+ {
+ public TestRunSessionRequest(string projectPath, bool debugging, string launchProfile, bool disableLaunchProfile)
+ {
+ launch_configurations = new TestLaunchConfiguration[]
+ {
+ new() {
+ project_path = projectPath,
+ type = RunSessionRequest.ProjectLaunchConfigurationType,
+ mode= debugging? RunSessionRequest.DebugLaunchMode : RunSessionRequest.NoDebugLaunchMode,
+ launch_profile = launchProfile,
+ disable_launch_profile = disableLaunchProfile
+ }
+ };
+ }
+ public TestLaunchConfiguration[] launch_configurations { get; set; }
+ public List env { get; set; } = new List();
+ public List args { get; set; } = new List();
+
+ public TestRunSessionRequestP4 ToTestRunSessionRequestP4()
+ {
+ var launchConfig = launch_configurations[0];
+ return new TestRunSessionRequestP4()
+ {
+ project_path = launchConfig.project_path,
+ debug = string.Equals(launchConfig.mode, RunSessionRequest.DebugLaunchMode, StringComparison.OrdinalIgnoreCase),
+ args = args,
+ env = env,
+ launch_profile = launchConfig.launch_profile,
+ disable_launch_profile = launchConfig.disable_launch_profile
+ };
+ }
+ }
+
+ internal class TestLaunchConfiguration
+ {
+ public string type { get; set; } = string.Empty;
+ public string project_path { get; set; } = string.Empty;
+ public string launch_profile { get; set; }
+ public bool disable_launch_profile { get; set; }
+ public string mode { get; set; } = string.Empty;
+ }
+
+ internal class TestStopSessionRequest
+ {
+ public string session_id { get; set; } = string.Empty;
+ }
+#pragma warning restore IDE1006 // Naming Styles
+}
+
+internal static class AspireServerServiceExtensions
+{
+ public static async Task WaitForListeningAsync(this AspireServerService aspireServer)
+ {
+ string serverAddress = (await aspireServer.GetServerVariablesAsync()).serverAddress;
+
+ // We need to wait on the port being available
+ await Helpers.CanConnectToPortAsync(new Uri($"http://{serverAddress}"), 5000, CancellationToken.None);
+
+ }
+
+ public static async Task<(string serverAddress, string bearerToken, string certToken)> GetServerVariablesAsync(this AspireServerService aspireServer)
+ {
+ var enVars = await aspireServer.GetServerConnectionEnvironmentAsync(CancellationToken.None);
+ return (enVars[0].Value, enVars[1].Value, enVars[2].Value);
+ }
+}
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj b/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj
new file mode 100644
index 000000000000..927652b1cd10
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+ $(SdkTargetFramework)
+ Microsoft.WebTools.AspireServer.UnitTests
+ enable
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/Helpers.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Helpers.cs
new file mode 100644
index 000000000000..19f6d17f15c3
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Helpers.cs
@@ -0,0 +1,181 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+public static class Helpers
+{
+ public static async Task CanConnectToPortAsync(Uri url, uint msToWait, CancellationToken cancelToken)
+ {
+ bool connected = false;
+ Socket? ipv4Socket = null;
+ Socket? ipv6Socket = null;
+
+ // Create a "client" socket on any available port
+ try
+ {
+ TimeoutSpan timeout = new(msToWait);
+ if (Socket.OSSupportsIPv4)
+ {
+ try
+ {
+ ipv4Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ ipv4Socket.Bind(new IPEndPoint(IPAddress.Any, 0));
+ }
+ catch (SocketException)
+ {
+ if (ipv4Socket != null)
+ {
+ ipv4Socket.Close();
+ ipv4Socket = null;
+ }
+ }
+ }
+ if (Socket.OSSupportsIPv6)
+ {
+ try
+ {
+ ipv6Socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp);
+ ipv6Socket.Bind(new IPEndPoint(IPAddress.IPv6Any, 0));
+ }
+ catch (SocketException)
+ {
+ if (ipv6Socket != null)
+ {
+ ipv6Socket.Close();
+ ipv6Socket = null;
+ }
+ }
+ }
+
+ // No sockets means we aren't connected
+ if (ipv6Socket == null && ipv4Socket == null)
+ {
+ return false;
+ }
+
+ // If we have an IP address we use that otherwise assume loopback
+ IPEndPoint ipv4ServerEndPoint;
+ IPEndPoint ipv6ServerEndPoint;
+ if (IPAddress.TryParse(url.Host, out var ipAddress))
+ {
+ ipv4ServerEndPoint = new IPEndPoint(ipAddress.AddressFamily == AddressFamily.InterNetwork ? ipAddress : IPAddress.Loopback, url.Port);
+ ipv6ServerEndPoint = new IPEndPoint(ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? ipAddress : IPAddress.IPv6Loopback, url.Port);
+ }
+ else
+ {
+ ipv4ServerEndPoint = new IPEndPoint(IPAddress.Loopback, url.Port);
+ ipv6ServerEndPoint = new IPEndPoint(IPAddress.IPv6Loopback, url.Port);
+ }
+
+ // If a process is passed in, we bail if it has exited
+ while (!connected && !timeout.Expired)
+ {
+ cancelToken.ThrowIfCancellationRequested();
+ if (ipv4Socket != null)
+ {
+ try
+ {
+ // Now use IOControl to set the calls non blocking
+ ipv4Socket.Blocking = false;
+ // Since we are non-blocking, the Connect should throw an error indicating
+ // it needs time to connect
+ ipv4Socket.Connect(ipv4ServerEndPoint);
+ }
+ catch (SocketException)
+ {
+ // Now ping retry and block for a millisecond timeout
+ ArrayList connectList = new ArrayList() {ipv4Socket};
+ Socket.Select(null, connectList, null, 1000 /*microSecond -- in here, 1 milli-second*/);
+ if (connectList.Count == 1)
+ {
+ connected = true;
+ break;
+ }
+ }
+ finally
+ {
+ // TODO: why do we set the sockets back to blocking?
+ ipv4Socket.Blocking = true;
+ }
+ }
+
+ // Now try IPV6
+ if (ipv6Socket != null)
+ {
+ // Couldn't connect with IPV4, so try IPV6
+ try
+ {
+ ipv6Socket.Blocking = false;
+ ipv6Socket.Connect(ipv6ServerEndPoint);
+ }
+ catch (SocketException)
+ {
+ // Ping retry
+ ArrayList connectList = new ArrayList() {ipv6Socket};
+ Socket.Select(null, connectList, null, 1000 /*microSecond -- in here, 1 milli-second*/);
+ if (connectList.Count == 1)
+ {
+ connected = true;
+ break;
+ }
+ }
+ finally
+ {
+ // TODO: why do we set the sockets back to blocking?
+ ipv6Socket.Blocking = true;
+ }
+ }
+
+ // Wait a bit and try again
+ await Task.Delay(20, cancelToken);
+ }
+
+ }
+ finally
+ {
+ if (ipv4Socket != null)
+ {
+ ipv4Socket.Close();
+ }
+
+ if (ipv6Socket != null)
+ {
+ ipv6Socket.Close();
+ }
+ }
+
+ return connected;
+ }
+}
+
+internal class TimeoutSpan
+{
+ private readonly long _duration;
+ private long _startingTickCount;
+
+ public TimeoutSpan(long durationInMilliseconds)
+ {
+ // There are 10000 ticks in a millisecond so need to adjust accordingly
+ _duration = durationInMilliseconds * 10000;
+ Reset();
+ }
+
+ public bool Expired
+ {
+ get
+ {
+ return _duration != 0 && (DateTime.UtcNow.Ticks - _startingTickCount) > _duration;
+ }
+ }
+
+ public void Reset()
+ {
+ // DateTime.UtcNow is way more efficient than DateTime.Now since it doesn't have to deal with locale, DST, etc
+ _startingTickCount = DateTime.UtcNow.Ticks;
+ }
+}
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs
new file mode 100644
index 000000000000..3c805871fdf4
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+
+using Microsoft.WebTools.AspireServer.Contracts;
+using Moq;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+internal class IAspireServerEventsMock : MockFactory
+{
+ public IAspireServerEventsMock(Mocks mocks, MockBehavior? mockBehavior = null)
+ : base(mocks, mockBehavior)
+ {
+ }
+
+ public IAspireServerEventsMock ImplementStartProjectAsync(string dcpId, string sessionId, Exception? ex = null)
+ {
+ MockObject.Setup(x => x.StartProjectAsync(dcpId, It.IsAny(), It.IsAny()))
+ .Returns(() =>
+ {
+ if (ex is not null)
+ {
+ throw ex;
+ }
+
+ return new ValueTask(sessionId);
+ })
+ .Verifiable();
+ return this;
+ }
+
+ public IAspireServerEventsMock ImplementStopSessionAsync(string dcpId, string sessionId, bool exists, Exception? ex = null)
+ {
+ MockObject.Setup(x => x.StopSessionAsync(dcpId, sessionId, It.IsAny()))
+ .Returns(() =>
+ {
+ if (ex is not null)
+ {
+ throw ex;
+ }
+
+ return new ValueTask(exists);
+ })
+ .Verifiable();
+ return this;
+ }
+}
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/IServiceProviderMock.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IServiceProviderMock.cs
new file mode 100644
index 000000000000..c975028c392f
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IServiceProviderMock.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Moq;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+internal class IServiceProviderMock : MockFactory
+{
+ public IServiceProviderMock(Mocks mocks, MockBehavior? mockBehavior = null)
+ : base(mocks, mockBehavior)
+ {
+ }
+
+ public IServiceProviderMock ImplementService(Type type, object service)
+ {
+ MockObject.Setup(x => x.GetService(type)).Returns(service);
+
+ return this;
+ }
+}
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/MockFactory.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/MockFactory.cs
new file mode 100644
index 000000000000..ef9f4467013c
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/MockFactory.cs
@@ -0,0 +1,36 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Moq;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+public interface IMockFactory
+{
+ void Verify();
+ object GetObject();
+}
+
+public class MockFactory : IMockFactory where T : class
+{
+ public MockFactory(Mocks mocks, MockBehavior? mockBehavior)
+ {
+ AllMocks = mocks;
+ MockObject = new Mock(mockBehavior ?? MockBehavior.Strict);
+ }
+
+ protected Mocks AllMocks { get; }
+ public Mock MockObject { get; }
+
+ public T Object => MockObject.Object;
+
+ public virtual void Verify()
+ {
+ MockObject.VerifyAll();
+ }
+
+ public object GetObject()
+ {
+ return Object;
+ }
+}
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/Mocks.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Mocks.cs
new file mode 100644
index 000000000000..0b546ad5b20e
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/Mocks.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Moq;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+public class Mocks
+{
+ private readonly Dictionary _mockFactories = new();
+
+ public void Add(IMockFactory factory)
+ {
+ _mockFactories.Add(factory.GetType(), factory);
+ }
+
+ public T GetOrCreate(MockBehavior? mockBehavior = null) where T : IMockFactory
+ {
+ if (_mockFactories.TryGetValue(typeof(T), out var factory))
+ {
+ return (T)factory;
+ }
+
+ var newMock = (IMockFactory?)Activator.CreateInstance(typeof(T), this, mockBehavior);
+ Debug.Assert(newMock != null);
+ Add(newMock);
+ return (T)newMock;
+ }
+
+ public virtual void Verify()
+ {
+ foreach (var factory in _mockFactories)
+ {
+ factory.Value.Verify();
+ }
+ }
+}
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Properties/AssemblyInfo.cs b/test/Microsoft.WebTools.AspireService.Tests/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000000..8b7c37cd90c4
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Properties/AssemblyInfo.cs
@@ -0,0 +1,6 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
diff --git a/test/Microsoft.WebTools.AspireService.Tests/Properties/launchSettings.json b/test/Microsoft.WebTools.AspireService.Tests/Properties/launchSettings.json
new file mode 100644
index 000000000000..2d63805b76a3
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "Microsoft.WebTools.AspireServer.Test": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:61653;http://localhost:61654"
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs b/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs
new file mode 100644
index 000000000000..4012d02bf10c
--- /dev/null
+++ b/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable disable
+
+using Microsoft.WebTools.AspireServer.Models;
+
+namespace Microsoft.WebTools.AspireServer.UnitTests;
+
+public class RunSessionRequestTests
+{
+ [Fact]
+ public void RunSessionRequest_ToProjectLaunchRequest()
+ {
+ var runSessionReq = new RunSessionRequest()
+ {
+ Arguments = new string[] { "--someArg" },
+ Environment = new EnvVar[]
+ {
+ new EnvVar { Name = "var1", Value = "value1"},
+ new EnvVar { Name = "var2", Value = "value2"},
+ },
+ LaunchConfigurations = new LaunchConfiguration[]
+ {
+ new() {
+ ProjectPath = @"c:\test\Projects\project1.csproj",
+ LaunchType = RunSessionRequest.ProjectLaunchConfigurationType,
+ LaunchMode= RunSessionRequest.DebugLaunchMode,
+ LaunchProfile = "specificProfileName",
+ DisableLaunchProfile = true
+ }
+ }
+ };
+
+ var projectReq = runSessionReq.ToProjectLaunchInformation();
+
+ Assert.Equal(runSessionReq.Arguments[0], projectReq.Arguments.First());
+ Assert.Equal(runSessionReq.Environment.Length, projectReq.Environment.Count());
+ Assert.Equal(runSessionReq.Environment[0].Name, projectReq.Environment.First().Key);
+ Assert.Equal(runSessionReq.Environment[0].Value, projectReq.Environment.First().Value);
+ Assert.Equal(runSessionReq.LaunchConfigurations[0].ProjectPath, projectReq.ProjectPath);
+ Assert.True(projectReq.Debug);
+ Assert.Equal(runSessionReq.LaunchConfigurations[0].LaunchProfile, projectReq.LaunchProfile);
+ Assert.Equal(runSessionReq.LaunchConfigurations[0].DisableLaunchProfile, projectReq.DisableLaunchProfile);
+ }
+}