From 5b49dc28f709a5b408b33bc8d79683b094b39a69 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Wed, 22 Jul 2026 19:07:45 -0400 Subject: [PATCH 1/2] Fix Test Tool v2 Blazor UI non-interactive on .NET 10 (static assets 404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On .NET 9+ the Blazor framework files (_framework/blazor.web.js) and the scoped-CSS bundle are served through the endpoint-routing MapStaticAssets API backed by the *.staticwebassets.endpoints.json manifest, not the classic static-files middleware. The tool only registered a wwwroot-only UseStaticFiles provider, so on net10 those assets returned 404, window.Blazor was never defined, no interactive server circuit was established, and the whole UI rendered statically (buttons/@onclick/@bind non-functional). Add a NET9_0_OR_GREATER-guarded app.MapStaticAssets() so net9+ serves the framework + scoped assets via the endpoints manifest while net8.0 keeps its existing path. Also disable the build-manifest dev-time runtime-patching handler (ReloadStaticAssetsAtRuntime=false), which otherwise probes those assets through the physical wwwroot provider and threw FileNotFoundException (HTTP 500) for _framework/* under dotnet run/build. Serve net10 Blazor framework assets with real bytes (fix 0-byte 200 responses) The previous fix made _framework/blazor.web.js return HTTP 200 but with an empty (0-byte) body, so window.Blazor was still never defined and the UI stayed non-interactive. Two root causes, both fixed here: 1. MapStaticAssets serves asset bytes from IWebHostEnvironment.WebRootFileProvider. Under dotnet run the framework files live in the NuGet cache and are mapped in via *.staticwebassets.runtime.json, but ASP.NET Core only composes that manifest into the web root automatically in the Development environment. The tool runs as Production by default, so the web root was a bare wwwroot provider and the invoker caught FileNotFoundException and returned an empty 200. Fixed by calling builder.WebHost.UseStaticWebAssets(), which composes the manifest regardless of environment (and is a harmless no-op for the installed tool, where the framework files are published directly into wwwroot). 2. As an installed global tool the process launches from an arbitrary working directory, and the default content root (hence WebRootFileProvider = contentRoot/wwwroot) followed the cwd, so MapStaticAssets looked in a nonexistent wwwroot and returned empty 200s for every asset. Fixed by pinning ContentRootPath to AppContext.BaseDirectory. Replaces the earlier ReloadStaticAssetsAtRuntime=false workaround, which only suppressed the dev hot-reload 500 but left MapStaticAssets serving 0 bytes. Verified on net10 with byte counts and a real browser (Playwright window.Blazor): - dotnet run: blazor.web.js=200575 bytes, styles.css=2024, bootstrap=232808; window.Blazor=true on / and /documentation. - installed global tool launched from a foreign cwd: same byte counts; window.Blazor=true on / and /documentation. net8 still builds and stays interactive (window.Blazor=true). Only MapStaticAssets when the manifest exists (fix net10 unit tests) TestToolProcess.Startup is invoked directly by unit tests (RuntimeApiTests, RunCommandTests) inside the xUnit test host. MapStaticAssets() throws InvalidOperationException when the *.staticwebassets.endpoints.json manifest is absent, and the manifest is named after the entry assembly — under the test host that's 'testhost', so the tool's manifest isn't present and every test that calls Startup failed on net10 (net8 was unaffected as it doesn't call MapStaticAssets). Guard the call so it only maps static assets when the expected manifest exists: the running tool has it (assets serve, UI is interactive); the test host does not (skipped, and those tests only exercise the Runtime API). Verified: the previously-failing net10 tests pass, and the real tool still serves _framework/blazor.web.js with real bytes. Fix RCL _content/** assets 404 on .NET 8 (dotnet run) The Blazor web UI's static-asset serving was only fully fixed for .NET 9+. On .NET 8, running from a build output (dotnet run / dotnet build) left Razor class library content — notably BlazorMonaco's _content/BlazorMonaco/** editor assets — returning 404, so the request/response code editor never initialized and selecting an example request could not populate the input. Root cause: those RCL assets are not physically under wwwroot in a build output; they are surfaced through the static-web-assets runtime manifest. UseStaticWebAssets() (which composes that manifest into the WebRootFileProvider) was guarded to net9+, and the classic UseStaticFiles middleware was pinned to an explicit bare-wwwroot provider that never reads the composed manifest. On net9+ MapStaticAssets reads the WebRootFileProvider so it worked there; net8 had no such reader. Fix: - Compose the static-web-assets manifest on all target frameworks (make UseStaticWebAssets() unconditional). - On .NET 8, add a second UseStaticFiles pass over the WebRootFileProvider so manifest-mapped _content/** assets resolve. For an installed global tool the WebRootFileProvider is the same wwwroot (assets published there directly), so it is a harmless second lookup. Verified across the full matrix — .NET 8/.NET 10 x Development/Production x dotnet-run/installed-tool (8/8 serve _framework, _content/BlazorMonaco, and app.css with non-zero bytes). --- .../156dd8b1-1af2-45fc-b051-b94dc51fc56f.json | 12 +++ .../Processes/TestToolProcess.cs | 87 +++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 .autover/changes/156dd8b1-1af2-45fc-b051-b94dc51fc56f.json diff --git a/.autover/changes/156dd8b1-1af2-45fc-b051-b94dc51fc56f.json b/.autover/changes/156dd8b1-1af2-45fc-b051-b94dc51fc56f.json new file mode 100644 index 000000000..0bb927899 --- /dev/null +++ b/.autover/changes/156dd8b1-1af2-45fc-b051-b94dc51fc56f.json @@ -0,0 +1,12 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.TestTool", + "Type": "Patch", + "ChangelogMessages": [ + "Fix Blazor web UI being non-interactive on .NET 9+/net10.0. Framework static assets (_framework/blazor.web.js) and the scoped-CSS bundle are now served via MapStaticAssets, the static web assets manifest is composed regardless of hosting environment, and the content root is pinned to the tool's install directory so the assets resolve correctly when running as an installed global tool.", + "Fix Razor class library content (e.g. the BlazorMonaco code-editor assets under _content/**) returning 404 on .NET 8 when running from a build output (dotnet run), which left the request/response editor uninitialized so selecting an example request could not populate the input. The static web assets manifest is now composed on all target frameworks, and .NET 8 additionally serves static files from the web root file provider so manifest-mapped _content/** assets resolve. Verified across .NET 8/.NET 10, Development/Production, and dotnet-run/installed-tool." + ] + } + ] +} diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs index 5da9d349b..6e8c8771b 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs @@ -36,10 +36,41 @@ public class TestToolProcess /// public static TestToolProcess Startup(RunCommandSettings settings, CancellationToken cancellationToken = default) { - var builder = WebApplication.CreateBuilder(); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + // Pin the content root to the tool's install directory rather than the current + // working directory. As an installed global tool the process is launched from an + // arbitrary cwd, and the default content root is the cwd. On .NET 9+ the framework + // and scoped-CSS assets are served by MapStaticAssets from the WebRootFileProvider, + // which is derived from the content root (contentRoot/wwwroot). If the content root + // is a foreign cwd, that provider points at a nonexistent wwwroot and MapStaticAssets + // returns empty (0-byte) 200 responses, leaving the Blazor UI non-interactive. + ContentRootPath = AppContext.BaseDirectory + }); Utils.ConfigureWebApplicationBuilder(builder); + // Static web assets (the *.staticwebassets.runtime.json manifest) map two kinds of files + // that do NOT physically live under wwwroot when running from a build output (dotnet run / + // dotnet build): the Blazor framework files (_framework/blazor.web.js) and Razor class + // library content such as BlazorMonaco's _content/BlazorMonaco/** editor assets. Both live + // in the NuGet cache and are surfaced via that manifest. ASP.NET Core only composes the + // manifest into the WebRootFileProvider automatically in the Development environment, but + // this tool runs in the Production environment by default, so without the call below the + // WebRootFileProvider is a bare wwwroot provider and those assets are unreachable. + // + // On .NET 9+ that leaves MapStaticAssets (added below) unable to find the framework files — + // it serves an empty (0-byte) HTTP 200, window.Blazor is never defined, and the whole UI is + // non-interactive. On .NET 8 the framework files are served by Blazor's own middleware, but + // the RCL _content/** assets are served through UseStaticFiles + WebRootFileProvider, so + // without the manifest the Monaco editor assets 404 and the code editor never initializes + // (e.g. selecting an example request cannot populate the input). Either way the fix is the + // same, so this runs on all target frameworks. + // + // When running as an installed global tool the manifest is absent (the framework and RCL + // content are published directly into wwwroot instead), so this is a harmless no-op there. + builder.WebHost.UseStaticWebAssets(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -91,15 +122,61 @@ public static TestToolProcess Startup(RunCommandSettings settings, CancellationT app.UseDeveloperExceptionPage(); } - // Always use the explicit file provider to serve static files from the tool's install - // directory. Without this, non-Production environments attempt to use the static web - // assets manifest which contains absolute paths from the build machine and will fail - // when running as an installed global tool on a different machine. + // Serve classic static files from the tool's install directory (wwwroot). This is the + // authoritative provider for an installed global tool, where every asset — app.css, the + // Blazor framework files, and RCL _content/** (e.g. BlazorMonaco) — is published directly + // into wwwroot. Pinning an explicit provider (rather than the default WebRootFileProvider) + // also avoids depending on the static-web-assets manifest, whose absolute build-machine + // paths would not resolve on another machine. app.UseStaticFiles(new StaticFileOptions { FileProvider = wwwrootFileProvider }); +#if !NET9_0_OR_GREATER + // On .NET 8 there is no MapStaticAssets endpoint pipeline (added below for net9+), and the + // explicit provider above sees only the physical wwwroot. When running from a build output + // (dotnet run / dotnet build), RCL _content/** assets are NOT physically in wwwroot — they + // are surfaced through the static-web-assets manifest that UseStaticWebAssets() composes + // into the WebRootFileProvider. Without a second pass over that provider, BlazorMonaco's + // _content/BlazorMonaco/** editor assets 404 and the code editor never initializes (so, for + // example, selecting an example request cannot populate the input). Serve from the + // WebRootFileProvider as well to cover that case. For an installed tool the WebRootFile + // provider resolves to the same wwwroot as above, so this is a harmless second lookup. + if (app.Environment.WebRootFileProvider is not null) + { + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = app.Environment.WebRootFileProvider + }); + } +#endif + +#if NET9_0_OR_GREATER + // On .NET 9+ the Blazor framework files (_framework/blazor.web.js) and the scoped-CSS + // bundle (Amazon.Lambda.TestTool.styles.css) are served through the endpoint-routing + // static assets API backed by the *.staticwebassets.endpoints.json manifest, not the + // classic static-files middleware above. Without this call those assets return 404, so + // window.Blazor is never defined and the interactive server circuit is never established, + // leaving the entire Blazor UI non-interactive. The bytes are resolved from the web host's + // WebRootFileProvider, which is why UseStaticWebAssets() is also required above (see the + // comment there). Guarded so net8.0 (which serves these assets via the classic static-web- + // assets middleware) keeps its existing behavior. + // + // MapStaticAssets() throws if the manifest is absent. That manifest is named after the + // entry assembly, so when Startup is invoked from within a test host (unit tests call it + // directly) the tool's manifest isn't next to the running 'testhost' assembly. Only map + // static assets when the expected manifest exists — the running tool has it; the test host + // does not (and those tests only exercise the Runtime API, not the Blazor assets). + var staticAssetsManifest = Path.Combine( + AppContext.BaseDirectory, + $"{System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name}.staticwebassets.endpoints.json"); + if (File.Exists(staticAssetsManifest)) + { + app.MapStaticAssets(); + } +#endif + app.UseAntiforgery(); app.MapRazorComponents() From e7f417d972e438db14af8eaf3352f8e7d5d1f6f3 Mon Sep 17 00:00:00 2001 From: Garrett Beatty Date: Fri, 31 Jul 2026 14:30:03 -0400 Subject: [PATCH 2/2] Simplify TestToolProcess static-asset comments and regroup per-TFM handling Address PR feedback on the three static-file registrations. No functional change: same UseStaticFiles/MapStaticAssets calls and guards, verified green across the full {net8,net10} x {Production,Development} x {dotnet run,installed} matrix. - Trim the verbose per-block comments (~54 -> ~28 lines) while keeping the facts. - Merge the separate #if !NET9 / #if NET9+ regions into one #if/#else placed right after the base UseStaticFiles, framing static assets as two layers: a manifest-independent base (classic wwwroot) plus per-TFM handling for the manifest-mapped framework/scoped-CSS/RCL _content assets. --- .../Processes/TestToolProcess.cs | 98 ++++++------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs index 6e8c8771b..6e3f6b19c 100644 --- a/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs +++ b/Tools/LambdaTestTool-v2/src/Amazon.Lambda.TestTool/Processes/TestToolProcess.cs @@ -38,37 +38,20 @@ public static TestToolProcess Startup(RunCommandSettings settings, CancellationT { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { - // Pin the content root to the tool's install directory rather than the current - // working directory. As an installed global tool the process is launched from an - // arbitrary cwd, and the default content root is the cwd. On .NET 9+ the framework - // and scoped-CSS assets are served by MapStaticAssets from the WebRootFileProvider, - // which is derived from the content root (contentRoot/wwwroot). If the content root - // is a foreign cwd, that provider points at a nonexistent wwwroot and MapStaticAssets - // returns empty (0-byte) 200 responses, leaving the Blazor UI non-interactive. + // Pin the content root to the install dir. As a global tool the process launches from + // an arbitrary cwd, and WebRootFileProvider (= contentRoot/wwwroot) defaults to it. A + // foreign cwd points that provider at a nonexistent wwwroot, so assets 404 / serve empty. ContentRootPath = AppContext.BaseDirectory }); Utils.ConfigureWebApplicationBuilder(builder); - // Static web assets (the *.staticwebassets.runtime.json manifest) map two kinds of files - // that do NOT physically live under wwwroot when running from a build output (dotnet run / - // dotnet build): the Blazor framework files (_framework/blazor.web.js) and Razor class - // library content such as BlazorMonaco's _content/BlazorMonaco/** editor assets. Both live - // in the NuGet cache and are surfaced via that manifest. ASP.NET Core only composes the - // manifest into the WebRootFileProvider automatically in the Development environment, but - // this tool runs in the Production environment by default, so without the call below the - // WebRootFileProvider is a bare wwwroot provider and those assets are unreachable. - // - // On .NET 9+ that leaves MapStaticAssets (added below) unable to find the framework files — - // it serves an empty (0-byte) HTTP 200, window.Blazor is never defined, and the whole UI is - // non-interactive. On .NET 8 the framework files are served by Blazor's own middleware, but - // the RCL _content/** assets are served through UseStaticFiles + WebRootFileProvider, so - // without the manifest the Monaco editor assets 404 and the code editor never initializes - // (e.g. selecting an example request cannot populate the input). Either way the fix is the - // same, so this runs on all target frameworks. - // - // When running as an installed global tool the manifest is absent (the framework and RCL - // content are published directly into wwwroot instead), so this is a harmless no-op there. + // Under `dotnet run`, the Blazor framework files (_framework/*) and RCL content + // (_content/BlazorMonaco/**) don't live in wwwroot — they're surfaced from the NuGet cache + // via the static-web-assets manifest. ASP.NET Core only composes that manifest into + // WebRootFileProvider automatically in Development, but this tool runs as Production, so we + // compose it explicitly here. Without it net9+ serves empty framework files (UI dead) and + // net8 404s the Monaco editor assets. No-op for the installed tool (everything's in wwwroot). builder.WebHost.UseStaticWebAssets(); builder.Services.AddSingleton(); @@ -122,52 +105,24 @@ public static TestToolProcess Startup(RunCommandSettings settings, CancellationT app.UseDeveloperExceptionPage(); } - // Serve classic static files from the tool's install directory (wwwroot). This is the - // authoritative provider for an installed global tool, where every asset — app.css, the - // Blazor framework files, and RCL _content/** (e.g. BlazorMonaco) — is published directly - // into wwwroot. Pinning an explicit provider (rather than the default WebRootFileProvider) - // also avoids depending on the static-web-assets manifest, whose absolute build-machine - // paths would not resolve on another machine. + // --- Static assets: a base layer for every TFM, then per-TFM handling for manifest assets --- + + // Base layer (all TFMs, all deployments): classic wwwroot files (app.css, images, favicon). + // Authoritative for the installed tool, where every asset is published into wwwroot. Pinned to + // an explicit provider so it never depends on the manifest's absolute build-machine paths. app.UseStaticFiles(new StaticFileOptions { FileProvider = wwwrootFileProvider }); -#if !NET9_0_OR_GREATER - // On .NET 8 there is no MapStaticAssets endpoint pipeline (added below for net9+), and the - // explicit provider above sees only the physical wwwroot. When running from a build output - // (dotnet run / dotnet build), RCL _content/** assets are NOT physically in wwwroot — they - // are surfaced through the static-web-assets manifest that UseStaticWebAssets() composes - // into the WebRootFileProvider. Without a second pass over that provider, BlazorMonaco's - // _content/BlazorMonaco/** editor assets 404 and the code editor never initializes (so, for - // example, selecting an example request cannot populate the input). Serve from the - // WebRootFileProvider as well to cover that case. For an installed tool the WebRootFile - // provider resolves to the same wwwroot as above, so this is a harmless second lookup. - if (app.Environment.WebRootFileProvider is not null) - { - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = app.Environment.WebRootFileProvider - }); - } -#endif - + // Manifest-mapped assets (framework files, scoped CSS, RCL _content/** e.g. Monaco) aren't in + // wwwroot under `dotnet run` — they're surfaced via the static-web-assets manifest that + // UseStaticWebAssets() composed above. How they're served differs by TFM: #if NET9_0_OR_GREATER - // On .NET 9+ the Blazor framework files (_framework/blazor.web.js) and the scoped-CSS - // bundle (Amazon.Lambda.TestTool.styles.css) are served through the endpoint-routing - // static assets API backed by the *.staticwebassets.endpoints.json manifest, not the - // classic static-files middleware above. Without this call those assets return 404, so - // window.Blazor is never defined and the interactive server circuit is never established, - // leaving the entire Blazor UI non-interactive. The bytes are resolved from the web host's - // WebRootFileProvider, which is why UseStaticWebAssets() is also required above (see the - // comment there). Guarded so net8.0 (which serves these assets via the classic static-web- - // assets middleware) keeps its existing behavior. - // - // MapStaticAssets() throws if the manifest is absent. That manifest is named after the - // entry assembly, so when Startup is invoked from within a test host (unit tests call it - // directly) the tool's manifest isn't next to the running 'testhost' assembly. Only map - // static assets when the expected manifest exists — the running tool has it; the test host - // does not (and those tests only exercise the Runtime API, not the Blazor assets). + // net9+: framework files + scoped CSS are served through the endpoint-routing static-assets + // API, not the classic middleware above. Without this, window.Blazor is never defined and the + // whole UI is non-interactive. MapStaticAssets() throws if the manifest (named after the entry + // assembly) is absent, as under a test host — so only map when it exists. var staticAssetsManifest = Path.Combine( AppContext.BaseDirectory, $"{System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name}.staticwebassets.endpoints.json"); @@ -175,6 +130,17 @@ public static TestToolProcess Startup(RunCommandSettings settings, CancellationT { app.MapStaticAssets(); } +#else + // net8: no MapStaticAssets, and the base provider sees only the physical wwwroot. Serve the + // manifest-composed WebRootFileProvider too, or the RCL _content/** (Monaco) assets 404 under + // `dotnet run`. For the installed tool this resolves to the same wwwroot — a harmless second pass. + if (app.Environment.WebRootFileProvider is not null) + { + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = app.Environment.WebRootFileProvider + }); + } #endif app.UseAntiforgery();