Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>annotations</Nullable>

<!-- blazor.webview.js ships as a normal package static web asset under _framework/.
blazor.modules.json is handled specially (see below + StaticWebAssets.Groups.targets). -->
<StaticWebAssetBasePath>/_framework</StaticWebAssetBasePath>
<!-- SWA Framework Assets: mark all JS files as Framework type during pack -->
<StaticWebAssetFrameworkPattern>**/*.js</StaticWebAssetFrameworkPattern>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -63,22 +63,15 @@
<Content Include="$(_BlazorWebViewJSContentRoot)\blazor.webview.js" Link="wwwroot\blazor.webview.js" ContentRoot="$(_BlazorWebViewJSContentRoot)\" />
</ItemGroup>

<!-- blazor.modules.json is a fallback for WebView apps that don't have JS library modules.
It is tagged with the BlazorWebViewModules=fallback group so it can be conditionally
included via a deferred group when the consumer has no JS library modules. -->
<!-- blazor.modules.json is an EMPTY ([]) fallback. It is shipped RAW (not as a package
static web asset, so it never appears in PackageAssets.json and never auto-flows) and is
materialized as the consumer's own static web asset by StaticWebAssets.Groups.targets
ONLY when the app contributes no JS library modules of its own. When the app DOES have
modules, the SDK generates _framework/blazor.modules.json and we add nothing, so the two
never collide. -->
<ItemGroup>
<Content Remove="blazor.modules.json" />
<Content Include="blazor.modules.json" Link="wwwroot\blazor.modules.json"
AssetTraitName="JSModule" AssetTraitValue="JSModuleManifest" />
</ItemGroup>

<ItemGroup>
<StaticWebAssetGroupDefinition Include="BlazorWebView"
Value="enabled" SourceId="$(PackageId)" Order="0"
IncludePattern="**" ExcludePattern="**/blazor.modules.json" />
<StaticWebAssetGroupDefinition Include="BlazorWebViewModules"
Value="fallback" SourceId="$(PackageId)" Order="1"
IncludePattern="**/blazor.modules.json" />
<None Include="blazor.modules.json" Pack="true" PackagePath="build\blazor.modules.json" />
</ItemGroup>

<!-- blazor.webview.js should exist after eng/Npm.Workspace.nodeproj builds. Warn if not. -->
Expand Down
126 changes: 55 additions & 71 deletions src/Components/WebView/WebView/src/StaticWebAssets.Groups.targets
Original file line number Diff line number Diff line change
@@ -1,90 +1,74 @@
<Project>
<!-- This file ships in the package's build/ folder and is auto-imported by consumers via
build/Microsoft.AspNetCore.Components.WebView.targets. (The SDK static web assets pack
imports a file named StaticWebAssets.Groups.targets by convention; the name is kept for
that reason even though this package no longer uses asset groups.)

Purpose: WebView apps fetch _framework/blazor.modules.json at runtime. When the app
contributes its own JS library modules, the SDK generates that manifest. When it does
not, nothing would be served, so this package ships an empty ([]) fallback and
materializes it as the consumer's own static web asset ONLY in that case. The decision is
made here, during ResolveStaticWebAssetsInputs (before the build manifest is generated and
its conflict check runs), so exactly one asset ever lands on the route and no group /
conflict-resolution machinery is required. -->
<PropertyGroup>
<!-- Emit the app's own JS module manifest at the path WebView serves. -->
<JSModuleManifestRelativePath>_framework/blazor.modules.json</JSModuleManifestRelativePath>
<CompressionEnabled>false</CompressionEnabled>
</PropertyGroup>

<ItemGroup>
<StaticWebAssetGroup Include="BlazorWebView"
Value="enabled"
SourceId="Microsoft.AspNetCore.Components.WebView" />
</ItemGroup>

<!-- BlazorWebViewModules group: resolves to 'default' when the consumer generates
a JS module manifest (JS library modules exist), or 'fallback' when it doesn't.
The SDK-generated manifest is tagged with BlazorWebViewModules=default by
_TagSdkModulesManifestWithGroup (runs before GenerateStaticWebAssetsManifest).
The WebView fallback blazor.modules.json is tagged with BlazorWebViewModules=fallback
via StaticWebAssetGroupDefinition in the csproj. Both coexist in the build manifest
(distinct AssetGroups), and FilterStaticWebAssetGroups picks the right one. -->

<!-- Target 1: Tag the SDK-generated JS module manifest with BlazorWebViewModules=default
before GenerateStaticWebAssetsManifest runs its conflict check. -->
<PropertyGroup>
<GenerateStaticWebAssetsManifestDependsOn>
$(GenerateStaticWebAssetsManifestDependsOn);
_TagSdkModulesManifestWithGroup
</GenerateStaticWebAssetsManifestDependsOn>
<ResolveStaticWebAssetsInputsDependsOn>
$(ResolveStaticWebAssetsInputsDependsOn);
_AddBlazorWebViewModulesFallback;
</ResolveStaticWebAssetsInputsDependsOn>
<!-- Ensure the app's JS module resolution has run so @(_ExistingBuildJSModules) is populated
before we decide whether to add the fallback. -->
<_AddBlazorWebViewModulesFallbackDependsOn Condition="'$(GenerateJSModuleManifest)' == 'true'">GenerateJSModuleManifestBuildStaticWebAssets</_AddBlazorWebViewModulesFallbackDependsOn>
</PropertyGroup>

<Target Name="_TagSdkModulesManifestWithGroup">
<Target Name="_AddBlazorWebViewModulesFallback"
DependsOnTargets="$(_AddBlazorWebViewModulesFallbackDependsOn)">
<PropertyGroup>
<_BlazorWebViewModulesFallbackRoot>$(MSBuildThisFileDirectory)</_BlazorWebViewModulesFallbackRoot>
<_BlazorWebViewModulesFallbackFile>$(_BlazorWebViewModulesFallbackRoot)blazor.modules.json</_BlazorWebViewModulesFallbackFile>
</PropertyGroup>

<ItemGroup>
<_SdkGeneratedModulesManifest Include="@(StaticWebAsset)"
Condition="'%(StaticWebAsset.AssetTraitName)' == 'JSModule' and '%(StaticWebAsset.AssetTraitValue)' == 'JSModuleManifest' and '%(StaticWebAsset.SourceType)' == 'Computed'" />
<_BlazorWebViewModulesFallbackCandidate
Include="$(_BlazorWebViewModulesFallbackFile)"
Condition="'@(_ExistingBuildJSModules)' == '' and Exists('$(_BlazorWebViewModulesFallbackFile)')">
<RelativePath>_framework/blazor.modules.json</RelativePath>
</_BlazorWebViewModulesFallbackCandidate>
</ItemGroup>

<!-- The SDK-generated manifest has AssetKind=Build and AssetMode=CurrentProject,
which means it won't appear in the development manifest or be served at runtime.
WebView needs it served, so promote it to AssetKind=All and AssetMode=All. -->
<ItemGroup Condition="'@(_SdkGeneratedModulesManifest)' != ''">
<StaticWebAsset Remove="@(_SdkGeneratedModulesManifest)" />
<StaticWebAsset Include="@(_SdkGeneratedModulesManifest)">
<AssetGroups>BlazorWebViewModules=default</AssetGroups>
<AssetKind>All</AssetKind>
<AssetMode>All</AssetMode>
</StaticWebAsset>
</ItemGroup>
</Target>
<Message Importance="Low"
Condition="'@(_BlazorWebViewModulesFallbackCandidate)' != ''"
Text="BlazorWebView: app has no JS library modules; adding fallback _framework/blazor.modules.json." />

<!-- Target 2: Resolve the deferred BlazorWebViewModules group after
ResolveStaticWebAssetsInputs has populated _ExistingBuildJSModules. -->
<PropertyGroup>
<FilterDeferredStaticWebAssetGroupsDependsOn>
$(FilterDeferredStaticWebAssetGroupsDependsOn);
_ResolveBlazorWebViewModulesGroup
</FilterDeferredStaticWebAssetGroupsDependsOn>
</PropertyGroup>
<DefineStaticWebAssets Condition="'@(_BlazorWebViewModulesFallbackCandidate)' != ''"
CandidateAssets="@(_BlazorWebViewModulesFallbackCandidate)"
SourceId="$(PackageId)"
SourceType="Discovered"
ContentRoot="$(_BlazorWebViewModulesFallbackRoot)"
BasePath="/"
AssetKind="All"
AssetMode="All"
AssetRole="Primary"
FingerprintCandidates="true">
<Output TaskParameter="Assets" ItemName="_BlazorWebViewModulesFallbackAsset" />
</DefineStaticWebAssets>

<ItemGroup>
<StaticWebAssetGroup Include="BlazorWebViewModules"
SourceId="Microsoft.AspNetCore.Components.WebView" Deferred="true" />
<!-- The SDK-generated manifest has SourceId=$(PackageId) (the consumer project),
so we need a matching group entry for that SourceId too. -->
<StaticWebAssetGroup Include="BlazorWebViewModules"
SourceId="$(PackageId)" Deferred="true" />
</ItemGroup>
<DefineStaticWebAssetEndpoints Condition="'@(_BlazorWebViewModulesFallbackAsset)' != ''"
CandidateAssets="@(_BlazorWebViewModulesFallbackAsset)"
ExistingEndpoints="@(StaticWebAssetEndpoint)"
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)">
<Output TaskParameter="Endpoints" ItemName="_BlazorWebViewModulesFallbackEndpoint" />
</DefineStaticWebAssetEndpoints>

<Target Name="_ResolveBlazorWebViewModulesGroup">
<ItemGroup>
<StaticWebAssetGroup Remove="BlazorWebViewModules" />
<!-- Group for WebView's fallback blazor.modules.json -->
<StaticWebAssetGroup Include="BlazorWebViewModules"
Value="default"
SourceId="Microsoft.AspNetCore.Components.WebView"
Condition="'@(_ExistingBuildJSModules)' != ''" />
<StaticWebAssetGroup Include="BlazorWebViewModules"
Value="fallback"
SourceId="Microsoft.AspNetCore.Components.WebView"
Condition="'@(_ExistingBuildJSModules)' == ''" />
<!-- Group for the consumer's SDK-generated manifest (SourceId=$(PackageId)) -->
<StaticWebAssetGroup Include="BlazorWebViewModules"
Value="default"
SourceId="$(PackageId)"
Condition="'@(_ExistingBuildJSModules)' != ''" />
<StaticWebAssetGroup Include="BlazorWebViewModules"
Value="fallback"
SourceId="$(PackageId)"
Condition="'@(_ExistingBuildJSModules)' == ''" />
<StaticWebAsset Include="@(_BlazorWebViewModulesFallbackAsset)" />
<StaticWebAssetEndpoint Include="@(_BlazorWebViewModulesFallbackEndpoint)" />
</ItemGroup>
</Target>
</Project>
209 changes: 209 additions & 0 deletions src/Components/WebView/test/StaticWebAssets/ConsumerBuild.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// 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 System.Runtime.CompilerServices;
using System.Text;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.WebView.StaticWebAssets;

/// <summary>
/// Creates a throwaway solution on disk that references the locally-built WebView package and runs
/// the repo SDK (.dotnet) to build/publish it. Used to validate that consuming the package produces
/// the expected static web asset endpoints (issue #67374).
///
/// Working folders live under the repo's artifacts/tmp directory (not the system temp folder), and
/// every build/publish captures a binary log under artifacts/log so failures can be diagnosed from
/// CI. The working folder is preserved when a build fails and removed on success.
/// </summary>
internal sealed class ConsumerBuild : IDisposable
{
private readonly ITestOutputHelper _output;
private readonly string _root;
private readonly string _packagesFolder;
private readonly string _id;
private bool _preserve;

public ConsumerBuild(ITestOutputHelper output, bool isolateNuGetFeeds = true, [CallerMemberName] string testName = "")
{
_output = output;
_id = $"{testName}-{Guid.NewGuid():N}";
_root = Path.Combine(StaticWebAssetsTestData.ArtifactsTmpDir, "ComponentsWebViewStaticWebAssetsTests", _id);
Directory.CreateDirectory(_root);
_packagesFolder = Path.Combine(_root, ".nuget-packages");

// Isolate the build from the repo and from any other test run.
File.WriteAllText(Path.Combine(_root, "Directory.Build.props"), "<Project />");
File.WriteAllText(Path.Combine(_root, "Directory.Build.targets"), "<Project />");

if (!isolateNuGetFeeds)
{
// ProjectReference (P2P) mode: the app references the WebView source project, so it needs
// no package feed of its own. Inherit the repo's NuGet.config (the working folder lives
// under the repo's artifacts) so the referenced project's dependencies resolve.
return;
}

// Use an isolated global-packages folder so the freshly-built package under test is never
// served stale from a shared cache, while adding the repo's package cache as a read-only
// fallback so the exact transitive package versions the repo restored (which may not be
// published to public feeds yet) can still be resolved.
var repoCache = StaticWebAssetsTestData.NuGetPackageRoot.TrimEnd('\\', '/');
File.WriteAllText(Path.Combine(_root, "NuGet.config"), $"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{_packagesFolder}" />
</config>
<fallbackPackageFolders>
<clear />
<add key="repo-cache" value="{repoCache}" />
</fallbackPackageFolders>
<packageSources>
<clear />
<add key="local-shipping" value="{StaticWebAssetsTestData.ShippingPackagesDir.TrimEnd('\\', '/')}" />
<add key="local-nonshipping" value="{StaticWebAssetsTestData.NonShippingPackagesDir.TrimEnd('\\', '/')}" />
<add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
""");
}

public string Root => _root;

public string CreateProject(string relativeDir, string fileName, string content)
{
var dir = Path.Combine(_root, relativeDir);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, fileName);
File.WriteAllText(path, content);
return path;
}

public void CreateFile(string relativePath, string content)
{
var path = Path.Combine(_root, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
}

/// <param name="verb">The dotnet verb plus its options, e.g. "publish -c Release".</param>
/// <param name="projectRelativePath">Project to build, relative to the working folder.</param>
public ProcessResult Run(string verb, string projectRelativePath)
{
// The package version under test is constant (e.g. 11.0.0-dev). Make sure a previously
// extracted copy in the shared repo cache (used as a fallback folder) can't shadow the
// freshly built package; restore will then pull it from the local feed.
EvictFromFallbackCache("Microsoft.AspNetCore.Components.WebView");

// Capture a binary log under artifacts/log so CI uploads it and failures can be analyzed.
var verbName = verb.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "build";
var binlogPath = Path.Combine(StaticWebAssetsTestData.ArtifactsLogDir, $"WebViewStaticWebAssets-{_id}-{verbName}.binlog");
Directory.CreateDirectory(StaticWebAssetsTestData.ArtifactsLogDir);

var arguments = $"{verb} \"{Path.Combine(_root, projectRelativePath)}\" -bl:\"{binlogPath}\"";
var psi = new ProcessStartInfo(StaticWebAssetsTestData.DotNetHost)
{
Arguments = arguments,
WorkingDirectory = _root,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};

// Keep the build hermetic: pin the repo runtime and stop the SDK from reaching outside the
// .dotnet folder. The global-packages folder is configured via NuGet.config.
psi.Environment["DOTNET_ROOT"] = Path.Combine(StaticWebAssetsTestData.RepoRoot, ".dotnet");
psi.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0";
psi.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1";
psi.Environment["DOTNET_NOLOGO"] = "1";
psi.Environment.Remove("MSBuildSDKsPath");

_output.WriteLine($"> dotnet {arguments}");

var output = new StringBuilder();
using var process = new Process { StartInfo = psi };
process.OutputDataReceived += (_, e) => { if (e.Data is not null) { lock (output) { output.AppendLine(e.Data); } } };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { lock (output) { output.AppendLine(e.Data); } } };

process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();

if (!process.WaitForExit(milliseconds: 5 * 60 * 1000))
{
try { process.Kill(entireProcessTree: true); } catch { }
_preserve = true;
throw new TimeoutException($"'dotnet {verb}' timed out. Binlog: {binlogPath}\n{output}");
}

process.WaitForExit();
var result = new ProcessResult(process.ExitCode, output.ToString(), binlogPath);

_output.WriteLine(result.Output);
_output.WriteLine($"Exit code: {result.ExitCode}. Binlog: {binlogPath}");
if (!result.Succeeded)
{
// Leave the working folder in place so the failure can be investigated locally.
_preserve = true;
}

return result;
}

public void Dispose()
{
if (_preserve)
{
_output.WriteLine($"Build failed; preserving working folder for investigation: {_root}");
return;
}

try
{
Directory.Delete(_root, recursive: true);
}
catch
{
// Best effort cleanup.
}
}

private static void EvictFromFallbackCache(string packageId)
{
var dir = Path.Combine(
StaticWebAssetsTestData.NuGetPackageRoot,
packageId.ToLowerInvariant(),
StaticWebAssetsTestData.PackageVersion);

try
{
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
}
}
catch
{
// Best effort; if it can't be removed restore may still succeed from the local feed.
}
}
}

internal sealed record ProcessResult(int ExitCode, string Output, string BinlogPath)
{
public bool Succeeded => ExitCode == 0;

/// <summary>
/// True when the failure looks like it was caused by an inability to reach the NuGet feeds rather
/// than a real build problem, so offline environments can skip instead of failing.
/// </summary>
public bool LooksLikeNetworkFailure
=> !Succeeded &&
(Output.Contains("Unable to load the service index", StringComparison.OrdinalIgnoreCase) ||
Output.Contains("NU1301", StringComparison.OrdinalIgnoreCase) ||
Output.Contains("Unable to resolve", StringComparison.OrdinalIgnoreCase) && Output.Contains("nuget", StringComparison.OrdinalIgnoreCase) ||
Output.Contains("The remote name could not be resolved", StringComparison.OrdinalIgnoreCase) ||
Output.Contains("No such host is known", StringComparison.OrdinalIgnoreCase));
}
Loading
Loading