Skip to content

Commit f5408ee

Browse files
committed
feat(dev-server): Add AssemblyInfoReader and improve app launch connection handling
- Enhanced `ApplicationLaunchMonitor.ReportConnection` to return success status. - Added detailed error logging for failed app launch connections. - Implemented `RealAppLaunchIntegrationTests` to validate app connections with the dev server. - Updated `TelemetryProcessorTests` with improved test DLL discovery and app instance support.
1 parent b822d73 commit f5408ee

13 files changed

Lines changed: 408 additions & 89 deletions

src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch;
1111
public class AppLaunchIntegrationTests : TelemetryTestBase
1212
{
1313
private static readonly string? _serverProcessorAssembly = ExternalDllDiscoveryHelper.DiscoverExternalDllPath(
14-
Logger,
14+
Logger,
1515
typeof(DevServerTestHelper).Assembly,
1616
projectName: "Uno.UI.RemoteControl.Server.Processors",
1717
dllFileName: "Uno.UI.RemoteControl.Server.Processors.dll");
@@ -24,7 +24,7 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted()
2424
{
2525
// PRE-ARRANGE: Create a solution file
2626
var solution = SolutionHelper!;
27-
await solution.CreateSolutionFile();
27+
await solution.CreateSolutionFileAsync();
2828

2929
var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success"));
3030
await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false);
@@ -54,12 +54,12 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted()
5454
[new ServerEndpointAttribute("localhost", helper.Port)],
5555
_serverProcessorAssembly,
5656
autoRegisterAppIdentity: true);
57-
57+
5858
await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10));
5959

6060
// ACT - STEP 3: Stop and gather telemetry events
6161
await Task.Delay(1500, CT);
62-
await helper.AttemptGracefulShutdown(CT);
62+
await helper.AttemptGracefulShutdownAsync(CT);
6363

6464
// ASSERT
6565
var events = ParseTelemetryFileIfExists(filePath);
@@ -87,7 +87,7 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeC
8787
{
8888
// PRE-ARRANGE: Create a solution file
8989
var solution = SolutionHelper!;
90-
await solution.CreateSolutionFile();
90+
await solution.CreateSolutionFileAsync();
9191

9292
var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success_idechannel"));
9393
await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: true);
@@ -122,7 +122,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)],
122122

123123
// ACT - STEP 3: Stop and gather telemetry events
124124
await Task.Delay(1500, CT);
125-
await helper.AttemptGracefulShutdown(CT);
125+
await helper.AttemptGracefulShutdownAsync(CT);
126126

127127
// ASSERT
128128
var events = ParseTelemetryFileIfExists(filePath);
@@ -143,12 +143,6 @@ [new ServerEndpointAttribute("localhost", helper.Port)],
143143
TestContext.WriteLine(helper.ConsoleOutput);
144144
}
145145
}
146-
147-
// [TestMethod]
148-
// public async Task WhenRegisteredAndNoRuntimeConnects_TimeoutEventEmitted()
149-
// {
150-
// throw new NotImplementedException("TODO");
151-
// }
152146

153147
private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path)
154148
=> File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : [];
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
using System.Diagnostics;
2+
using System.Text;
3+
using System.Text.Json;
4+
using System.Net.Http;
5+
using Uno.UI.RemoteControl.DevServer.Tests.Telemetry;
6+
using Uno.UI.RemoteControl.DevServer.Tests.Helpers;
7+
8+
namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch;
9+
10+
[TestClass]
11+
public class RealAppLaunchIntegrationTests : TelemetryTestBase
12+
{
13+
[ClassInitialize]
14+
public static void ClassInitialize(TestContext context) => GlobalClassInitialize<RealAppLaunchIntegrationTests>(context);
15+
16+
[TestMethod]
17+
public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished()
18+
{
19+
// PRE-ARRANGE: Create a real Uno solution file (will contain desktop project)
20+
var solution = SolutionHelper!;
21+
await solution.CreateSolutionFileAsync();
22+
23+
var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_app_success"));
24+
await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false);
25+
26+
Process? appProcess = null;
27+
try
28+
{
29+
// ARRANGE
30+
var started = await helper.StartAsync(CT);
31+
helper.EnsureStarted();
32+
33+
// Build the App (Skia desktop) project with devserver configuration
34+
var projectPath = await BuildAppProjectAsync(solution, helper.Port);
35+
36+
// ACT - STEP 1: Read MVID and Target Platform from built assembly and register app launch (IDE -> devserver)
37+
await RegisterAppLaunchAsync(projectPath, helper.Port);
38+
39+
// ACT - STEP 2: Start the real Skia desktop application that will eventually connect to devserver
40+
appProcess = await StartSkiaDesktopAppAsync(projectPath, helper.Port);
41+
42+
// ACT - STEP 3: Wait for the real connection to be established
43+
await WaitForAppToConnectoToDevServerAsync(helper, TimeSpan.FromSeconds(30));
44+
45+
// ASSERT
46+
await Task.Delay(3000, CT);
47+
await helper.AttemptGracefulShutdownAsync(CT);
48+
49+
var events = ParseTelemetryFileIfExists(filePath);
50+
started.Should().BeTrue("Dev server should start successfully");
51+
52+
WriteEventsList(events);
53+
54+
events.Should().NotBeEmpty();
55+
AssertHasEvent(events, "uno/dev-server/app-launch/launched");
56+
AssertHasEvent(events, "uno/dev-server/app-launch/connected");
57+
58+
helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output");
59+
}
60+
finally
61+
{
62+
// Clean up app process if it's still running
63+
if (appProcess is { HasExited: false })
64+
{
65+
try
66+
{
67+
appProcess.Kill();
68+
appProcess.WaitForExit(5000);
69+
}
70+
catch (Exception ex)
71+
{
72+
TestContext!.WriteLine($"Error stopping Skia process: {ex.Message}");
73+
}
74+
appProcess.Dispose();
75+
}
76+
77+
await helper.StopAsync(CT);
78+
DeleteIfExists(filePath);
79+
80+
TestContext!.WriteLine("Dev Server Output:");
81+
TestContext.WriteLine(helper.ConsoleOutput);
82+
}
83+
}
84+
85+
private async Task RegisterAppLaunchAsync(string projectPath, int httpPort)
86+
{
87+
var projectDir = Path.GetDirectoryName(projectPath)!;
88+
var assemblyName = Path.GetFileNameWithoutExtension(projectPath);
89+
var tfm = "net9.0-desktop";
90+
var assemblyPath = Path.Combine(projectDir, "bin", "Debug", tfm, assemblyName + ".dll");
91+
92+
TestContext!.WriteLine($"Reading assembly info from: {assemblyPath}");
93+
var (mvid, platformName) = AssemblyInfoReader.Read(assemblyPath);
94+
var platform = platformName ?? "Desktop";
95+
96+
using (var http = new HttpClient())
97+
{
98+
var url = $"http://localhost:{httpPort}/applaunch/{mvid}?platform={Uri.EscapeDataString(platform)}&isDebug=false";
99+
TestContext!.WriteLine($"Registering app launch: {url}");
100+
var response = await http.GetAsync(url, CT);
101+
response.EnsureSuccessStatusCode();
102+
}
103+
}
104+
105+
/// <summary>
106+
/// Builds the Skia desktop project from the generated solution with devserver configuration.
107+
/// </summary>
108+
private async Task<string> BuildAppProjectAsync(SolutionHelper solution, int devServerPort)
109+
{
110+
// Find the desktop project path in the generated solution
111+
var solutionDir = Path.GetDirectoryName(solution.SolutionFile)!;
112+
113+
// Look for the project to compile (there's only one in the solution)
114+
var appProject = Directory.GetFiles(solutionDir, "*.csproj", SearchOption.AllDirectories).SingleOrDefault();
115+
116+
if (appProject == null)
117+
{
118+
throw new InvalidOperationException("Could not find a project in the generated solution");
119+
}
120+
121+
TestContext!.WriteLine($"Building desktop project: {appProject}");
122+
123+
// Build the project with devserver configuration so the generators create the right ServerEndpointAttribute
124+
// Using MSBuild properties directly to override any .csproj.user or Directory.Build.props values
125+
var buildInfo = new ProcessStartInfo
126+
{
127+
FileName = "dotnet",
128+
Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}",
129+
RedirectStandardOutput = true,
130+
RedirectStandardError = true,
131+
UseShellExecute = false,
132+
CreateNoWindow = true,
133+
WorkingDirectory = Path.GetDirectoryName(appProject)!,
134+
};
135+
136+
var (exitCode, output) = await ProcessUtil.RunProcessAsync(buildInfo);
137+
138+
TestContext!.WriteLine($"Build output: {output}");
139+
140+
if (exitCode != 0)
141+
{
142+
throw new InvalidOperationException($"dotnet build failed with exit code {exitCode}. Output:\n{output}");
143+
}
144+
145+
return appProject;
146+
}
147+
148+
/// <summary>
149+
/// Starts the Skia desktop application with devserver connection enabled.
150+
/// </summary>
151+
private async Task<Process> StartSkiaDesktopAppAsync(string projectPath, int devServerPort)
152+
{
153+
// Before starting the app, make sure it will run with the freshly compiled RemoteControlClient
154+
try
155+
{
156+
var projectDir = Path.GetDirectoryName(projectPath)!;
157+
var appTfm = "net9.0-desktop";
158+
var appOutputDir = Path.Combine(projectDir, "bin", "Debug", appTfm);
159+
var freshRcDll = typeof(Uno.UI.RemoteControl.RemoteControlClient).Assembly.Location;
160+
var destRcDll = Path.Combine(appOutputDir, Path.GetFileName(freshRcDll));
161+
162+
Directory.CreateDirectory(appOutputDir);
163+
File.Copy(freshRcDll, destRcDll, overwrite: true);
164+
// Also copy PDB if available for better diagnostics
165+
var freshRcPdb = Path.ChangeExtension(freshRcDll, ".pdb");
166+
var destRcPdb = Path.ChangeExtension(destRcDll, ".pdb");
167+
if (File.Exists(freshRcPdb))
168+
{
169+
File.Copy(freshRcPdb, destRcPdb, overwrite: true);
170+
}
171+
TestContext!.WriteLine($"Overwrote RemoteControlClient assembly: {destRcDll}");
172+
}
173+
catch (Exception copyEx)
174+
{
175+
TestContext!.WriteLine($"Warning: Failed to overwrite RemoteControlClient assembly: {copyEx}");
176+
}
177+
178+
var runInfo = new ProcessStartInfo
179+
{
180+
FileName = "dotnet",
181+
Arguments = $"run --project \"{projectPath}\" --configuration Debug --framework net9.0-desktop --no-build",
182+
RedirectStandardOutput = true,
183+
RedirectStandardError = true,
184+
UseShellExecute = false,
185+
CreateNoWindow = true,
186+
WorkingDirectory = Path.GetDirectoryName(projectPath)!,
187+
};
188+
189+
// Set environment for clean execution (no devserver config needed here since it's baked into the build)
190+
runInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en";
191+
192+
var process = new Process { StartInfo = runInfo };
193+
194+
// Set up output capturing for diagnostic purposes
195+
var outputBuilder = new StringBuilder();
196+
process.OutputDataReceived += (sender, e) =>
197+
{
198+
if (e.Data != null)
199+
{
200+
outputBuilder.AppendLine(e.Data);
201+
TestContext!.WriteLine($"[APP-OUT] {e.Data}");
202+
}
203+
};
204+
205+
process.ErrorDataReceived += (sender, e) =>
206+
{
207+
if (e.Data != null)
208+
{
209+
outputBuilder.AppendLine(e.Data);
210+
TestContext!.WriteLine($"[APP-ERR] {e.Data}");
211+
}
212+
};
213+
214+
process.Start();
215+
process.BeginOutputReadLine();
216+
process.BeginErrorReadLine();
217+
218+
TestContext!.WriteLine($"Started Skia desktop app process with PID: {process.Id}");
219+
220+
// Wait a moment for the app to start
221+
await Task.Delay(2000, CT);
222+
223+
if (process.HasExited)
224+
{
225+
throw new InvalidOperationException($"Skia app exited immediately with code {process.ExitCode}. Output: {outputBuilder}");
226+
}
227+
228+
return process;
229+
}
230+
231+
/// <summary>
232+
/// Waits for the Skia application to connect to the devserver.
233+
/// This will be a real connection test - the app should connect on its own through the generated ServerEndpointAttribute.
234+
/// </summary>
235+
private async Task WaitForAppToConnectoToDevServerAsync(DevServerTestHelper helper, TimeSpan timeout)
236+
{
237+
var startTime = Stopwatch.GetTimestamp();
238+
239+
TestContext!.WriteLine("Waiting for real Skia app to connect to devserver...");
240+
TestContext!.WriteLine("The app should connect automatically via the generated ServerEndpointAttribute during build.");
241+
242+
// For this integration test, we'll wait a reasonable time for the app to start
243+
// and assume success if no catastrophic errors occur. The goal is to verify
244+
// that a real Skia Desktop app can be built and launched with devserver configuration.
245+
246+
var connectionDetected = false;
247+
var iterations = 0;
248+
const int maxIterations = 15; // 15 seconds max wait
249+
250+
while (iterations < maxIterations && !connectionDetected)
251+
{
252+
await Task.Delay(1000, CT);
253+
iterations++;
254+
255+
// Check if we can see the app connection in devserver output
256+
var devServerOutput = helper.ConsoleOutput;
257+
258+
TestContext!.WriteLine($"[{iterations}/{maxIterations}] Checking devserver output... ({devServerOutput.Length} chars)");
259+
260+
// Look for connection success indicators
261+
if (devServerOutput.Contains("App Connected:"))
262+
{
263+
TestContext!.WriteLine("✅ SUCCESS: Skia app successfully connected to devserver!");
264+
TestContext!.WriteLine("Connection detected in devserver logs - integration test objective achieved.");
265+
connectionDetected = true;
266+
break;
267+
}
268+
}
269+
270+
if (!connectionDetected)
271+
{
272+
TestContext!.WriteLine("⚠️ Connection not detected in logs, but test may still be successful.");
273+
TestContext!.WriteLine("The real Skia app was built and launched successfully with devserver configuration.");
274+
TestContext!.WriteLine($"DevServer output: {helper.ConsoleOutput}");
275+
}
276+
}
277+
278+
private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path)
279+
=> File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : [];
280+
281+
private static void DeleteIfExists(string path)
282+
{
283+
if (File.Exists(path)) { try { File.Delete(path); } catch { } }
284+
}
285+
}

0 commit comments

Comments
 (0)