Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
445d334
Stop copying WASM assets to bin/wwwroot/_framework during build
maraf Apr 1, 2026
1037300
Skip bin/_framework/ bundle assertions for build
lewing Apr 3, 2026
fc681c1
Merge branch 'main' into maraf/WasmSdkCopyToBin
maraf Apr 10, 2026
b21ad54
Assert framework files in obj/ for MultiClientHostedBuild
maraf Apr 10, 2026
41b60c3
Assert build bundle files in obj/ instead of skipping for non-publish
maraf Apr 10, 2026
9e1c91a
Merge branch 'main' into maraf/WasmSdkCopyToBin
maraf Apr 14, 2026
c5040e2
Fix AssertBuildBundle for NoWebcil configs and satellite test paths
maraf Apr 15, 2026
f239571
Apply PR review feedback: fix comment, named args, remove unused var
maraf Apr 15, 2026
6815cdb
Add .wasm assertion to multi-client build path
maraf Apr 15, 2026
f830463
Merge branch 'main' into maraf/WasmSdkCopyToBin
lewing Apr 16, 2026
07d1d49
Apply review feedback: remove accidental symlink, validate multi-thre…
maraf Apr 15, 2026
9e284fd
Fix wasm build tests for CopyToOutputDirectory=Never
maraf Apr 17, 2026
00635ac
Extract materialized framework dir discovery into shared helper
maraf Apr 17, 2026
c6fe5e5
Address Copilot code-review bot findings
maraf Apr 17, 2026
906e584
Fix BuildThenPublishWithAOT: stat unfingerprinted dotnet.runtime.js i…
maraf Apr 20, 2026
07f446a
Merge branch 'main' into maraf/WasmSdkCopyToBin
maraf Apr 21, 2026
8eb1a80
Add hosted-scenario runtime check to MultiClientHostedBuildAndPublish
maraf Apr 21, 2026
40bdbab
Add _WasmFrameworkCopyToOutputDirectory property to control framework…
Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eng/testing/tests.browser.targets
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
<UseSystemResourceKeys Condition="'$(UseSystemResourceKeys)' == ''">false</UseSystemResourceKeys>
<EventSourceSupport Condition="'$(EventSourceSupport)' == ''">true</EventSourceSupport>
<NullabilityInfoContextSupport Condition="'$(NullabilityInfoContextSupport)' == ''">true</NullabilityInfoContextSupport>

<!-- Library tests run directly from bin/ without the static web assets middleware,
so copy framework files to the output directory. See https://github.com/dotnet/runtime/issues/127257. -->
<_WasmFrameworkCopyToOutputDirectory>PreserveNewest</_WasmFrameworkCopyToOutputDirectory>
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overrides _WasmFrameworkCopyToOutputDirectory unconditionally. To keep the file overrideable (and consistent with the surrounding properties in this PropertyGroup), consider setting it only when it’s not already set (e.g., add a Condition checking for empty).

Suggested change
<_WasmFrameworkCopyToOutputDirectory>PreserveNewest</_WasmFrameworkCopyToOutputDirectory>
<_WasmFrameworkCopyToOutputDirectory Condition="'$(_WasmFrameworkCopyToOutputDirectory)' == ''">PreserveNewest</_WasmFrameworkCopyToOutputDirectory>

Copilot uses AI. Check for mistakes.
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeFlavor)' == 'CoreCLR'">
<WasmTestSupport>true</WasmTestSupport>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ Copyright (c) .NET Foundation. All rights reserved.
<PropertyGroup>
<_WasmBuildWebcilPath>$([MSBuild]::NormalizeDirectory($(IntermediateOutputPath), 'webcil'))</_WasmBuildWebcilPath>
<_WasmBuildTmpWebcilPath>$([MSBuild]::NormalizeDirectory($(IntermediateOutputPath), 'tmp-webcil'))</_WasmBuildTmpWebcilPath>
<!-- Controls whether WASM framework/webcil static web assets are copied to bin/wwwroot/_framework/
at build time. Defaults to 'Never' (assets stay in obj/fx/ and are served via the static web
assets middleware). Set to 'PreserveNewest' to copy them to the output directory — this is
required for scenarios that run the app from bin/ without the static web assets middleware
(e.g. in-tree library tests). -->
<_WasmFrameworkCopyToOutputDirectory Condition="'$(_WasmFrameworkCopyToOutputDirectory)' == ''">Never</_WasmFrameworkCopyToOutputDirectory>
</PropertyGroup>

<ConvertDllsToWebcil Candidates="@(_BuildAssetsCandidates)" IntermediateOutputPath="$(_WasmBuildTmpWebcilPath)" OutputPath="$(_WasmBuildWebcilPath)" IsEnabled="$(_WasmEnableWebcil)" WebcilVersion="$(_WasmWebcilVersion)">
Expand Down Expand Up @@ -387,7 +393,7 @@ Copyright (c) .NET Foundation. All rights reserved.
SourceType="Computed"
AssetKind="Build"
AssetRole="Primary"
CopyToOutputDirectory="PreserveNewest"
CopyToOutputDirectory="$(_WasmFrameworkCopyToOutputDirectory)"
CopyToPublishDirectory="Never"
FingerprintCandidates="$(_WasmFingerprintAssets)"
FingerprintPatterns="@(FingerprintPatterns);@(_WasmFingerprintPatterns)"
Expand All @@ -406,7 +412,7 @@ Copyright (c) .NET Foundation. All rights reserved.
SourceType="Framework"
AssetKind="Build"
AssetRole="Primary"
CopyToOutputDirectory="PreserveNewest"
CopyToOutputDirectory="$(_WasmFrameworkCopyToOutputDirectory)"
CopyToPublishDirectory="Never"
FingerprintCandidates="$(_WasmFingerprintAssets)"
FingerprintPatterns="@(FingerprintPatterns);@(_WasmFingerprintPatterns)"
Expand All @@ -430,12 +436,12 @@ Copyright (c) .NET Foundation. All rights reserved.
<!-- Materialized framework assets must be visible to referencing projects (e.g. Blazor WASM
hosted scenarios where the server project serves the client's framework files).
UpdatePackageStaticWebAssets defaults AssetMode to CurrentProject and CopyToOutputDirectory
to Never. Override both: AssetMode=All so assets flow through project references, and
CopyToOutputDirectory=PreserveNewest so they are copied from the intermediate materialized
path (obj/fx/{SourceId}/) to bin/wwwroot/_framework/ at build time. -->
to Never. Override AssetMode=All so assets flow through project references. Framework
files stay in obj/fx/{SourceId}/ and are served by the static web assets middleware
during dotnet run — they are not copied to bin/wwwroot/_framework/ at build time. -->
<ItemGroup>
<_WasmMaterializedFrameworkAssets Update="@(_WasmMaterializedFrameworkAssets)"
AssetMode="All" CopyToOutputDirectory="PreserveNewest" />
AssetMode="All" CopyToOutputDirectory="$(_WasmFrameworkCopyToOutputDirectory)" />
<WasmStaticWebAsset Include="@(_WasmMaterializedFrameworkAssets)" />
</ItemGroup>

Expand Down
16 changes: 14 additions & 2 deletions src/mono/wasm/Wasm.Build.Tests/Blazor/BuildPublishTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,26 @@ public void DefaultTemplate_WithResources_Publish(Configuration config)

Utils.DirectoryCopy(resxSourcePath, Path.Combine(_projectDir, "resx"));

// Build and assert resource dlls
// Build and assert resource dlls. With CopyToOutputDirectory=Never, framework files
// (including satellite assemblies) are no longer copied to bin/_framework/ during build.
// Webcil-converted assemblies live under obj/{config}/{tfm}/webcil/{locale}/; when webcil
// is disabled they're materialized under obj/{config}/{tfm}/fx/{name}/_framework/{locale}/.
BlazorBuild(info, config);
AssertResourcesDlls(GetBlazorBinFrameworkDir(config, forPublish: false));
AssertResourcesDlls(GetBuildSatelliteBaseDir());

// Publish and assert resource dlls
BlazorPublish(info, config, new PublishOptions(UseCache: false));
AssertResourcesDlls(GetBlazorBinFrameworkDir(config, forPublish: true));

string GetBuildSatelliteBaseDir()
{
string objDir = Path.Combine(_projectDir, "obj", config.ToString(), DefaultTargetFrameworkForBlazor);
if (UseWebcil)
return Path.Combine(objDir, "webcil");

return WasmSdkBasedProjectProvider.GetMaterializedFrameworkDir(objDir);
}

void AssertResourcesDlls(string basePath)
{
foreach (string culture in cultures)
Expand Down
49 changes: 43 additions & 6 deletions src/mono/wasm/Wasm.Build.Tests/Blazor/MiscTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.NET.Sdk.WebAssembly;
using Microsoft.Playwright;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
Expand Down Expand Up @@ -107,7 +108,7 @@ public void BugRegression_60479_WithRazorClassLib()
[InlineData(Configuration.Release, false)]
[InlineData(Configuration.Debug, true)]
[InlineData(Configuration.Release, true)]
public void MultiClientHostedBuildAndPublish(Configuration config, bool publish)
public async Task MultiClientHostedBuildAndPublish(Configuration config, bool publish)
{
// Test that two Blazor WASM client projects can be built/published by a single server
// project without duplicate static web asset Identity collisions. This validates the
Expand Down Expand Up @@ -148,16 +149,52 @@ public void MultiClientHostedBuildAndPublish(Configuration config, bool publish)
}
else
{
string client1Framework = Path.Combine(client1Dir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "wwwroot", "_framework");
string client2Framework = Path.Combine(client2Dir, "bin", config.ToString(), DefaultTargetFrameworkForBlazor, "wwwroot", "_framework");

Assert.True(Directory.Exists(client1Framework), $"Client1 framework dir missing: {client1Framework}");
Assert.True(Directory.Exists(client2Framework), $"Client2 framework dir missing: {client2Framework}");
// With CopyToOutputDirectory=Never, framework files are no longer copied to
// bin/_framework/ during build. UpdatePackageStaticWebAssets materializes them
// under obj/<config>/<tfm>/fx/<source-id>/_framework/ instead (the source-id
// folder name comes from static web assets metadata and isn't necessarily the
// project directory name), and the static web assets middleware serves them
// from there during dotnet run.
string client1ObjDir = Path.Combine(client1Dir, "obj", config.ToString(), DefaultTargetFrameworkForBlazor);
string client2ObjDir = Path.Combine(client2Dir, "obj", config.ToString(), DefaultTargetFrameworkForBlazor);
string client1Framework = WasmSdkBasedProjectProvider.GetMaterializedFrameworkDir(client1ObjDir);
string client2Framework = WasmSdkBasedProjectProvider.GetMaterializedFrameworkDir(client2ObjDir);

var client1Files = Directory.GetFiles(client1Framework);
var client2Files = Directory.GetFiles(client2Framework);
Assert.Contains(client1Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
Assert.Contains(client2Files, f => Path.GetFileName(f).StartsWith("dotnet.") && f.EndsWith(".js"));
Assert.Contains(client1Files, f => Path.GetFileName(f).Contains("dotnet.native") && f.EndsWith(".wasm"));
Assert.Contains(client2Files, f => Path.GetFileName(f).Contains("dotnet.native") && f.EndsWith(".wasm"));

// Start the server via `dotnet run --no-build` and verify that the static web assets
// middleware serves each referenced client's framework files from its obj/ materialized
// directory. This guards the hosted scenario the inline comment in
// Microsoft.NET.Sdk.WebAssembly.Browser.targets calls out: a server project consuming
// client framework files after `dotnet build` only (no publish). The Development
// environment is required so the Web SDK auto-invokes UseStaticWebAssets() and loads
// the client projects' static web assets manifests.
using RunCommand runCmd = new RunCommand(s_buildEnv, _testOutput);
runCmd.WithEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
ToolCommand serverCmd = runCmd.WithWorkingDirectory(serverDir);
await using BrowserRunner runner = new BrowserRunner(_testOutput);
string serverUrl = await runner.StartServerAndGetUrlAsync(
serverCmd, $"run -c {config} --no-build");
// Kestrel logs the listening URL as http://[::]:<port> (wildcard) which is not a valid
// Host header — replace the host with localhost for client requests.
string clientBaseUrl = serverUrl.Replace("[::]", "localhost");

IBrowser browser = await runner.SpawnBrowserAsync(serverUrl);
IBrowserContext context = await browser.NewContextAsync(
new() { BaseURL = clientBaseUrl, IgnoreHTTPSErrors = true });
foreach (string clientBase in new[] { "client1", "client2" })
{
string assetUrl = $"/{clientBase}/_framework/dotnet.js";
_testOutput.WriteLine($"Fetching {clientBaseUrl}{assetUrl} via Playwright");
var response = await context.APIRequest.GetAsync(assetUrl);
Assert.True(response.Ok,
$"Expected 2xx for {clientBaseUrl}{assetUrl} but got {response.Status} {response.StatusText}");
}
}
}
}
29 changes: 28 additions & 1 deletion src/mono/wasm/Wasm.Build.Tests/BuildPublishTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,35 @@ public async Task BuildThenPublishWithAOT(Configuration config, bool aot)
(_, string output) = BuildProject(info, config, new BuildOptions(Label: "first_build", AOT: aot), isNativeBuild: aot);

BuildPaths paths = GetBuildPaths(config, forPublish: isPublish);
// With CopyToOutputDirectory=Never, framework files aren't copied to bin/_framework/
// during build. They live in obj subdirs: dotnet.native.* in obj/wasm/for-build/
// (for native rebuilds like Release+AOT), and JS/source maps in obj/{config}/{tfm}/fx/{source-id}/_framework/.
// The boot config (dotnet.js) is at obj/{config}/{tfm}/dotnet.js.
string fxFrameworkDir = WasmSdkBasedProjectProvider.GetMaterializedFrameworkDir(paths.ObjDir);

BuildPaths buildObjPaths = paths with { BinFrameworkDir = fxFrameworkDir };
IDictionary<string, (string fullPath, bool unchanged)> pathsDict =
GetFilesTable(info.ProjectName, aot, paths, unchanged: false);
GetFilesTable(info.ProjectName, aot, buildObjPaths, unchanged: false, bootConfigDir: paths.ObjDir);

// dotnet.native.* are produced by the native rebuild into obj/wasm/for-build/ using
Comment on lines +69 to +73
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, pathsDict is repurposed later for publish output paths, but then reused again for the “second build” StatFiles(pathsDict) / CompareStat(...) checks. After the publish step, pathsDict points at publish paths, so the second-build stat/compare is no longer validating build outputs. Consider keeping separate dictionaries for build vs publish (or recomputing pathsDict/BuildPaths for the second build) before calling StatFiles/CompareStat for the build phase.

Copilot uses AI. Check for mistakes.
// their canonical (non-fingerprinted) names — fingerprinting is applied later when
// publishing to bin.
foreach (var nativeName in new[] { "dotnet.native.wasm", "dotnet.native.js" })
{
if (pathsDict.TryGetValue(nativeName, out var entry))
{
pathsDict[nativeName] = (Path.Combine(paths.ObjWasmDir, nativeName), entry.unchanged);
}
}

// dotnet.runtime.js lives in the materialized fx dir under its canonical (non-fingerprinted)
// name during build — fingerprinting is applied later when publishing to bin. GetFilesTable
// rewrites this entry to the fingerprinted name (from the boot config, which already reflects
// the publish layout), so override it back to the unfingerprinted obj/fx path.
if (pathsDict.TryGetValue("dotnet.runtime.js", out var runtimeJsEntry))
{
pathsDict["dotnet.runtime.js"] = (Path.Combine(fxFrameworkDir, "dotnet.runtime.js"), runtimeJsEntry.unchanged);
}

string mainDll = $"{info.ProjectName}.dll";
var firstBuildStat = StatFiles(pathsDict);
Expand Down
32 changes: 27 additions & 5 deletions src/mono/wasm/Wasm.Build.Tests/ModuleConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,33 @@ private void SymbolMapFileEmittedCore(bool emitSymbolMap, bool isPublish)
else
BuildProject(info, config, new BuildOptions(AssertAppBundle: false));

string frameworkDir = GetBinFrameworkDir(config, forPublish: isPublish);

// The file may be fingerprinted (e.g. dotnet.native.<hash>.js.symbols),
// so use a glob pattern to find it.
bool symbolsFileExists = Directory.EnumerateFiles(frameworkDir, "dotnet.native*.js.symbols").Any();
// Locate the emitted symbols file. With CopyToOutputDirectory=Never, framework files are
// no longer in bin/_framework during build: the native symbols file lives in
// obj/{config}/{tfm}/wasm/for-build/ (native rebuild) and the materialized copy ends up
// in obj/{config}/{tfm}/fx/{name}/_framework/. The publish path still has them in
// bin/{config}/{tfm}/publish/wwwroot/_framework/.
// The file may be fingerprinted (e.g. dotnet.native.<hash>.js.symbols), so use a glob.
const string symbolsPattern = "dotnet.native*.js.symbols";
bool symbolsFileExists;
if (isPublish)
{
string frameworkDir = GetBinFrameworkDir(config, forPublish: true);
symbolsFileExists = Directory.EnumerateFiles(frameworkDir, symbolsPattern).Any();
}
else
{
string objDir = Path.Combine(_projectDir, "obj", config.ToString(), DefaultTargetFramework);
string fxBaseDir = Path.Combine(objDir, "fx");
string[] searchDirs = [
Path.Combine(objDir, "wasm", "for-build"),
.. Directory.Exists(fxBaseDir)
? Directory.GetDirectories(fxBaseDir).Select(d => Path.Combine(d, "_framework"))
: Array.Empty<string>()
];
symbolsFileExists = searchDirs
.Where(Directory.Exists)
.Any(d => Directory.EnumerateFiles(d, symbolsPattern).Any());
}
Assert.Equal(emitSymbolMap, symbolsFileExists);
}
}
4 changes: 2 additions & 2 deletions src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ private string[] GetFilesMatchingNameConsideringFingerprinting(string filePath,
return dict;
}

public IDictionary<string, (string fullPath, bool unchanged)> GetFilesTable(string projectName, bool isAOT, BuildPaths paths, bool unchanged)
public IDictionary<string, (string fullPath, bool unchanged)> GetFilesTable(string projectName, bool isAOT, BuildPaths paths, bool unchanged, string? bootConfigDir = null)
{
List<string> files = new()
{
Expand Down Expand Up @@ -379,7 +379,7 @@ private string[] GetFilesMatchingNameConsideringFingerprinting(string filePath,

if (IsFingerprintingEnabled)
{
string bootJsonPath = GetBootConfigPath(paths.BinFrameworkDir, "dotnet.js");
string bootJsonPath = GetBootConfigPath(bootConfigDir ?? paths.BinFrameworkDir, "dotnet.js");
BootJsonData bootJson = GetBootJson(bootJsonPath);
AssetsData assets = (AssetsData)bootJson.resources;
var keysToUpdate = new List<string>();
Expand Down
13 changes: 9 additions & 4 deletions src/mono/wasm/Wasm.Build.Tests/SatelliteLoadingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,20 @@ public void SatelliteAssembliesFromPackageReference()
ProjectInfo info = CopyTestAsset(config, false, TestAsset.WasmBasicTestApp, "SatelliteLoadingTestsFromPackageReference");
BuildProject(info, config, new BuildOptions(ExtraMSBuildArgs: "-p:TestSatelliteAssembliesFromPackage=true"));

string binFrameworkDir = GetBinFrameworkDir(config, forPublish: false);
// With CopyToOutputDirectory=Never, satellite assemblies are no longer in
// bin/_framework/. When webcil is enabled they're in obj/{config}/{tfm}/webcil/{locale}/;
// when disabled they're materialized in obj/{config}/{tfm}/fx/{name}/_framework/{locale}/.
string objDir = Path.Combine(_projectDir, "obj", config.ToString(), DefaultTargetFramework);
string satelliteBaseDir = UseWebcil
? Path.Combine(objDir, "webcil")
: WasmSdkBasedProjectProvider.GetMaterializedFrameworkDir(objDir);

// Microsoft.CodeAnalysis.CSharp has satellite assemblies for multiple locales
// Verify that at least some of them are present in the AppBundle
string[] expectedLocales = ["cs", "de", "es", "fr", "it", "ja", "ko", "pl", "pt-BR", "ru", "tr", "zh-Hans", "zh-Hant"];
foreach (string locale in expectedLocales)
{
string satelliteDir = Path.Combine(binFrameworkDir, locale);
Assert.True(Directory.Exists(satelliteDir), $"Expected satellite directory '{locale}' to exist in {binFrameworkDir}");
string satelliteDir = Path.Combine(satelliteBaseDir, locale);
Assert.True(Directory.Exists(satelliteDir), $"Expected satellite directory '{locale}' to exist in {satelliteBaseDir}");

string[] satelliteFiles = Directory.GetFiles(satelliteDir, "Microsoft.CodeAnalysis.CSharp.resources*");
Assert.True(satelliteFiles.Length > 0, $"Expected Microsoft.CodeAnalysis.CSharp.resources.dll in {satelliteDir}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,8 +497,8 @@ public string GetObjDir(Configuration config, string? framework = null, string?
public BuildPaths GetBuildPaths(Configuration config, bool forPublish, string? projectDir = null) =>
_provider.GetBuildPaths(config, forPublish, projectDir);

public IDictionary<string, (string fullPath, bool unchanged)> GetFilesTable(string projectName, bool isAOT, BuildPaths paths, bool unchanged) =>
_provider.GetFilesTable(projectName, isAOT, paths, unchanged);
public IDictionary<string, (string fullPath, bool unchanged)> GetFilesTable(string projectName, bool isAOT, BuildPaths paths, bool unchanged, string? bootConfigDir = null) =>
_provider.GetFilesTable(projectName, isAOT, paths, unchanged, bootConfigDir);

public IDictionary<string, FileStat> StatFiles(IDictionary<string, (string fullPath, bool unchanged)> fullpaths) =>
_provider.StatFiles(fullpaths);
Expand Down
Loading
Loading