diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj b/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj index c83139c69a34..b6d476906fb3 100644 --- a/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/Templates.Blazor.Tests.csproj @@ -47,6 +47,13 @@ + + + PreserveNewest + + + + diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/TestAssets/WebWorker/TestWorkerMethods.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/TestAssets/WebWorker/TestWorkerMethods.cs new file mode 100644 index 000000000000..4bba70c73df8 --- /dev/null +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/TestAssets/WebWorker/TestWorkerMethods.cs @@ -0,0 +1,26 @@ +// 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.InteropServices.JavaScript; +using System.Runtime.Versioning; +using System.Text.Json; + +namespace TestWorkerExports; + +[SupportedOSPlatform("browser")] +public static partial class TestWorkerMethods +{ + [JSExport] + public static int Add(int a, int b) => a + b; + + [JSExport] + public static string Echo(string input) => input; + + [JSExport] + public static string GetPersonJson() + => JsonSerializer.Serialize(new { Name = "Alice", Age = 30 }); + + [JSExport] + public static string ThrowError() + => throw new InvalidOperationException("Test exception from worker"); +} diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/TestAssets/WebWorker/WebWorkerTest.razor b/src/ProjectTemplates/test/Templates.Blazor.Tests/TestAssets/WebWorker/WebWorkerTest.razor new file mode 100644 index 000000000000..2ff7e39594ff --- /dev/null +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/TestAssets/WebWorker/WebWorkerTest.razor @@ -0,0 +1,125 @@ +@* Licensed to the .NET Foundation under one or more agreements. *@ +@* The .NET Foundation licenses this file to you under the MIT license. *@ + +@page "/webworker-test" + +@using WorkerLib +@inject IJSRuntime JSRuntime +@implements IAsyncDisposable + +WebWorker Test + +
+

WebWorker E2E Test

+ +
+ + @InitStatus +
+ +
+ + @AddResult +
+ +
+ + @EchoResult +
+ +
+ + @ErrorResult +
+ +
+ + @DisposeStatus +
+
+ +@code { + private WebWorkerClient? Worker; + private string InitStatus = "Not initialized"; + private string AddResult = ""; + private string EchoResult = ""; + private string ErrorResult = ""; + private string DisposeStatus = ""; + + private bool IsReady => Worker != null && DisposeStatus != "Disposed"; + + private async Task InitWorker() + { + try + { + InitStatus = "Initializing..."; + StateHasChanged(); + await Task.Yield(); + + Worker = await WebWorkerClient.CreateAsync(JSRuntime); + InitStatus = "Ready"; + } + catch (Exception ex) + { + InitStatus = $"Error: {ex.Message}"; + Console.WriteLine($"[WebWorkerTest] Init error: {ex}"); + } + } + + private async Task TestAdd() + { + try + { + var result = await Worker!.InvokeAsync("TestWorkerExports.TestWorkerMethods.Add", [5, 3]); + AddResult = result == 8 ? "8" : $"Unexpected: {result}"; + } + catch (Exception ex) + { + AddResult = $"Error: {ex.Message}"; + } + } + + private async Task TestEcho() + { + try + { + var result = await Worker!.InvokeAsync("TestWorkerExports.TestWorkerMethods.Echo", ["Hello Worker"]); + EchoResult = result == "Hello Worker" ? "Hello Worker" : $"Unexpected: {result}"; + } + catch (Exception ex) + { + EchoResult = $"Error: {ex.Message}"; + } + } + + private async Task TestError() + { + try + { + await Worker!.InvokeAsync("TestWorkerExports.TestWorkerMethods.ThrowError", []); + ErrorResult = "No error thrown"; + } + catch (Exception ex) + { + ErrorResult = ex.Message.Contains("Test exception") ? "Caught expected error" : $"Wrong error: {ex.Message}"; + } + } + + private async Task DisposeWorker() + { + if (Worker != null) + { + await Worker.DisposeAsync(); + Worker = null; + DisposeStatus = "Disposed"; + } + } + + public async ValueTask DisposeAsync() + { + if (Worker != null) + { + await Worker.DisposeAsync(); + } + } +} diff --git a/src/ProjectTemplates/test/Templates.Blazor.Tests/WebWorkerTemplateE2ETest.cs b/src/ProjectTemplates/test/Templates.Blazor.Tests/WebWorkerTemplateE2ETest.cs new file mode 100644 index 000000000000..245ee84027fe --- /dev/null +++ b/src/ProjectTemplates/test/Templates.Blazor.Tests/WebWorkerTemplateE2ETest.cs @@ -0,0 +1,323 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Reflection; +using Microsoft.AspNetCore.BrowserTesting; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Playwright; +using Templates.Test.Helpers; +using Microsoft.AspNetCore.InternalTesting; +using Xunit.Abstractions; + +namespace BlazorTemplates.Tests; + +#pragma warning disable xUnit1041 // Fixture arguments to test classes must have fixture sources + +public class WebWorkerTemplateE2ETest(ProjectFactoryFixture projectFactory) : BlazorTemplateTest(projectFactory) +{ + public override string ProjectType => "blazorwasm"; + + private static readonly string TestAssetsPath = Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), + "TestAssets", + "WebWorker"); + + private static Project _sharedHostProject; + private static bool _hostInitialized; + + protected override async Task InitializeCoreAsync(TestContext context) + { + await base.InitializeCoreAsync(context); + + if (!_hostInitialized) + { + _sharedHostProject = await CreateBuildPublishAsync(onlyCreate: true); + CopyTestAssets(_sharedHostProject); + AddHostProjectSettings(_sharedHostProject); + _hostInitialized = true; + } + } + + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task WebWorkerTemplate_CanInvokeMethods(BrowserKind browserKind) + { + await using var testRun = await SetupWorkerLibAndBuild(_sharedHostProject); + + using var aspNetProcess = _sharedHostProject.StartBuiltProjectAsync(); + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", _sharedHostProject, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestWebWorkerInteraction(browserKind, aspNetProcess.ListeningUri.AbsoluteUri + "webworker-test"); + } + + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task WebWorkerTemplate_HandlesErrors(BrowserKind browserKind) + { + await using var testRun = await SetupWorkerLibAndBuild(_sharedHostProject); + + using var aspNetProcess = _sharedHostProject.StartBuiltProjectAsync(); + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", _sharedHostProject, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestWebWorkerErrorHandling(browserKind, aspNetProcess.ListeningUri.AbsoluteUri + "webworker-test"); + } + + [Theory] + [InlineData(BrowserKind.Chromium)] + public async Task WebWorkerTemplate_CanDisposeWorker(BrowserKind browserKind) + { + await using var testRun = await SetupWorkerLibAndBuild(_sharedHostProject); + + using var aspNetProcess = _sharedHostProject.StartBuiltProjectAsync(); + Assert.False( + aspNetProcess.Process.HasExited, + ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", _sharedHostProject, aspNetProcess.Process)); + + await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await TestWebWorkerDisposal(browserKind, aspNetProcess.ListeningUri.AbsoluteUri + "webworker-test"); + } + + private async Task SetupWorkerLibAndBuild(Project hostProject) + { + var parentDir = Path.GetDirectoryName(hostProject.TemplateOutputDir); + var workerLibDir = Path.Combine(parentDir, "WorkerLib"); + + if (Directory.Exists(workerLibDir)) + { + Directory.Delete(workerLibDir, recursive: true); + } + Directory.CreateDirectory(workerLibDir); + + await CreateWebWorkerLibrary(workerLibDir); + await AddWorkerLibReferenceAsync(hostProject); + await hostProject.RunDotNetBuildAsync(); + + return new WorkerLibTestRun(workerLibDir, hostProject, Output); + } + + private sealed class WorkerLibTestRun(string workerLibDir, Project hostProject, ITestOutputHelper output) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + using var result = ProcessEx.Run( + output, + hostProject.TemplateOutputDir, + DotNetMuxer.MuxerPathOrDefault(), + "remove reference ../WorkerLib/WorkerLib.csproj"); + await result.Exited; + + if (Directory.Exists(workerLibDir)) + { + try { Directory.Delete(workerLibDir, recursive: true); } + catch { /* Best effort cleanup */ } + } + } + } + + private async Task CreateWebWorkerLibrary(string workerLibDir) + { + var hiveArg = $"--debug:disable-sdk-templates --debug:custom-hive \"{TemplatePackageInstaller.CustomHivePath}\""; + var args = $"new webworker {hiveArg} -n WorkerLib -o \"{workerLibDir}\""; + + using var result = ProcessEx.Run(Output, AppContext.BaseDirectory, DotNetMuxer.MuxerPathOrDefault(), args); + await result.Exited; + Assert.True(result.ExitCode == 0, $"Failed to create webworker template: {result.Output}\n{result.Error}"); + + ModifyWorkerLibProjectFile(workerLibDir); + + using var restoreResult = ProcessEx.Run(Output, workerLibDir, DotNetMuxer.MuxerPathOrDefault(), "restore"); + await restoreResult.Exited; + Assert.True(restoreResult.ExitCode == 0, $"Failed to restore webworker library: {restoreResult.Output}\n{restoreResult.Error}"); + } + + private static void ModifyWorkerLibProjectFile(string workerLibDir) + { + var csprojPath = Path.Combine(workerLibDir, "WorkerLib.csproj"); + var content = File.ReadAllText(csprojPath); + + if (!content.Contains("AllowUnsafeBlocks")) + { + content = content.Replace( + "", + " true\n "); + File.WriteAllText(csprojPath, content); + } + } + + private static void AddHostProjectSettings(Project hostProject) + { + var csprojPath = Path.Combine(hostProject.TemplateOutputDir, $"{hostProject.ProjectName}.csproj"); + var content = File.ReadAllText(csprojPath); + + var settings = @" + + true + true + + + + + +"; + content = content.Replace("", settings + ""); + File.WriteAllText(csprojPath, content); + } + + private async Task AddWorkerLibReferenceAsync(Project hostProject) + { + using var result = ProcessEx.Run( + Output, + hostProject.TemplateOutputDir, + DotNetMuxer.MuxerPathOrDefault(), + "add reference ../WorkerLib/WorkerLib.csproj"); + await result.Exited; + Assert.True(result.ExitCode == 0, $"Failed to add WorkerLib reference: {result.Output}\n{result.Error}"); + } + + private void CopyTestAssets(Project hostProject) + { + var testComponentSource = Path.Combine(TestAssetsPath, "WebWorkerTest.razor"); + var testComponentContent = File.ReadAllText(testComponentSource); + + var pagesDir = Path.Combine(hostProject.TemplateOutputDir, "Components", "Pages"); + if (!Directory.Exists(pagesDir)) + { + pagesDir = Path.Combine(hostProject.TemplateOutputDir, "Pages"); + } + File.WriteAllText( + Path.Combine(pagesDir, "WebWorkerTest.razor"), + testComponentContent); + + var workerMethodsSource = Path.Combine(TestAssetsPath, "TestWorkerMethods.cs"); + File.Copy(workerMethodsSource, Path.Combine(hostProject.TemplateOutputDir, "TestWorkerMethods.cs"), overwrite: true); + } + + private async Task TestWebWorkerInteraction(BrowserKind browserKind, string baseUri) + { + if (!BrowserManager.IsAvailable(browserKind)) + { + EnsureBrowserAvailable(browserKind); + return; + } + + await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + + await page.GotoAsync(baseUri); + await page.WaitForSelectorAsync("#webworker-test", new() { Timeout = 15000 }); + + await page.ClickAsync("#btn-init"); + await WaitForWorkerInit(page); + + await page.ClickAsync("#btn-add"); + await WaitForElementText(page, "#add-result", "8", timeout: 30000); + + await page.ClickAsync("#btn-echo"); + await WaitForElementText(page, "#echo-result", "Hello Worker", timeout: 30000); + + await page.ClickAsync("#btn-dispose"); + await WaitForElementText(page, "#dispose-status", "Disposed"); + + await page.CloseAsync(); + } + + private async Task TestWebWorkerErrorHandling(BrowserKind browserKind, string baseUri) + { + if (!BrowserManager.IsAvailable(browserKind)) + { + EnsureBrowserAvailable(browserKind); + return; + } + + await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + + await page.GotoAsync(baseUri); + await page.WaitForSelectorAsync("#webworker-test", new() { Timeout = 15000 }); + + await page.ClickAsync("#btn-init"); + await WaitForWorkerInit(page); + + await page.ClickAsync("#btn-error"); + await WaitForElementText(page, "#error-result", "Caught expected error", timeout: 30000); + + await page.ClickAsync("#btn-dispose"); + await WaitForElementText(page, "#dispose-status", "Disposed"); + + await page.CloseAsync(); + } + + private async Task TestWebWorkerDisposal(BrowserKind browserKind, string baseUri) + { + if (!BrowserManager.IsAvailable(browserKind)) + { + EnsureBrowserAvailable(browserKind); + return; + } + + await using var browser = await BrowserManager.GetBrowserInstance(browserKind, BrowserContextInfo); + var page = await browser.NewPageAsync(); + + await page.GotoAsync(baseUri); + await page.WaitForSelectorAsync("#webworker-test", new() { Timeout = 15000 }); + + await page.ClickAsync("#btn-init"); + await WaitForWorkerInit(page); + + await page.ClickAsync("#btn-dispose"); + await WaitForElementText(page, "#dispose-status", "Disposed"); + + await page.CloseAsync(); + } + + private async Task WaitForElementText(IPage page, string selector, string expectedText, int timeout = 5000) + { + try + { + await page.WaitForFunctionAsync( + $"() => document.querySelector('{selector}')?.textContent === '{expectedText}'", + new PageWaitForFunctionOptions { Timeout = timeout }); + } + catch (TimeoutException) + { + var actualText = await page.EvaluateAsync($"document.querySelector('{selector}')?.textContent ?? 'element not found'"); + throw new TimeoutException($"Timeout waiting for '{selector}' to have text '{expectedText}'. Actual text: '{actualText}'"); + } + } + + private async Task WaitForWorkerInit(IPage page, int timeout = 60000) + { + try + { + await page.WaitForFunctionAsync( + """ + () => { + const status = document.querySelector('#init-status')?.textContent; + return status === 'Ready' || (status && status.startsWith('Error:')); + } + """, + new PageWaitForFunctionOptions { Timeout = timeout }); + } + catch (TimeoutException) + { + var actualStatus = await page.EvaluateAsync("document.querySelector('#init-status')?.textContent ?? 'element not found'"); + throw new TimeoutException($"Worker initialization timed out after {timeout}ms. Init status: '{actualStatus}'"); + } + + var initStatus = await page.EvaluateAsync("document.querySelector('#init-status')?.textContent"); + if (initStatus?.StartsWith("Error:", StringComparison.Ordinal) == true) + { + throw new InvalidOperationException($"Worker initialization failed: {initStatus}"); + } + + Output.WriteLine($"Worker initialized successfully. Status: {initStatus}"); + } +}