Skip to content
Open
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 @@ -24,6 +24,9 @@ public override bool Execute()

var result = CandidateEndpoints;

var routeSegments = new List<PathTokenizer.Segment>();
var basePathSegments = new List<PathTokenizer.Segment>();

for (var i = 0; i < CandidateEndpoints.Length; i++)
{
var candidateEndpoint = StaticWebAssetEndpoint.FromTaskItem(CandidateEndpoints[i]);
Expand All @@ -35,7 +38,7 @@ public override bool Execute()
// destined to be used as a reference by other project are passed to this task.

var oldRoute = candidateEndpoint.Route;
if (oldRoute.StartsWith(asset.BasePath))
if (StaticWebAssetEndpoint.RouteHasPathPrefix(oldRoute, asset.BasePath, routeSegments, basePathSegments))
{
Log.LogMessage(MessageImportance.Low, "Skipping endpoint '{0}' because route '{1}' is already updated.", asset.Identity, oldRoute);
}
Expand Down
34 changes: 34 additions & 0 deletions src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -538,4 +538,38 @@ void ITaskItem.CopyMetadataTo(ITaskItem destinationItem)
IDictionary ITaskItem.CloneCustomMetadata() => ((ITaskItem2)this).CloneCustomMetadataEscaped();

#endregion

public static bool RouteHasPathPrefix(
ReadOnlySpan<char> route,
ReadOnlySpan<char> prefix,
List<PathTokenizer.Segment> routeSegments,
List<PathTokenizer.Segment> prefixSegments)
{
routeSegments.Clear();
prefixSegments.Clear();

var routeTokenizer = new PathTokenizer(route);
var routeSegmentCollection = routeTokenizer.Fill(routeSegments);

var prefixTokenizer = new PathTokenizer(prefix);
var prefixSegmentCollection = prefixTokenizer.Fill(prefixSegments);

if (prefixSegmentCollection.Count > routeSegmentCollection.Count)
{
return false;
}

for (var i = 0; i < prefixSegmentCollection.Count; i++)
{
var prefixSegmentSpan = prefixSegmentCollection[i];
var routeSegmentSpan = routeSegmentCollection[i];

if (!prefixSegmentSpan.Equals(routeSegmentSpan, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,95 @@ public void FiltersOutEndpointsForAssetsNotFound()
task.Endpoints[0].GetMetadata("AssetFile").Should().Be(Path.GetFullPath(Path.Combine("wwwroot", "candidate.js")));
}

[Fact]
public void AppliesBasePathWhenRouteStartsWithBasePathButNotAsPathSegment()
{
// This test verifies the fix for a bug where routes like "App1.styles.css"
// were incorrectly skipped because they start with the BasePath "App1".
// The correct behavior is that the base path should only be considered
// "already applied" if the route starts with "App1/" (as a path segment),
// not just any string starting with "App1".
var errorMessages = new List<string>();
var buildEngine = new Mock<IBuildEngine>();
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));

// Create an asset with BasePath "App1" and a route "App1.styles.css"
// The route starts with "App1" but NOT "App1/", so the base path should still be applied
var task = new ComputeEndpointsForReferenceStaticWebAssets
{
BuildEngine = buildEngine.Object,
Assets = [CreateCandidate(
Path.Combine("obj", "scopedcss", "bundle", "App1.styles.css"),
"App1",
"Project",
"App1.styles.css",
"All",
"CurrentProject",
basePath: "App1")],
CandidateEndpoints = [CreateCandidateEndpoint("App1.styles.css", Path.Combine("obj", "scopedcss", "bundle", "App1.styles.css"))]
};

// Act
var result = task.Execute();

// Assert
result.Should().Be(true);
task.Endpoints.Should().ContainSingle();
// The route should be "App1/App1.styles.css", not just "App1.styles.css"
task.Endpoints[0].ItemSpec.Should().Be("App1/App1.styles.css");
}

[Fact]
public void SkipsBasePathApplicationWhenRouteAlreadyHasBasePathAsPathSegment()
{
// This test verifies that routes already starting with "BasePath/" are correctly skipped
var errorMessages = new List<string>();
var buildEngine = new Mock<IBuildEngine>();
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));

var task = new ComputeEndpointsForReferenceStaticWebAssets
{
BuildEngine = buildEngine.Object,
Assets = [CreateCandidate(
Path.Combine("wwwroot", "css", "app.css"),
"App1",
"Discovered",
"css/app.css",
"All",
"All",
basePath: "App1")],
// Route already has the base path as a path segment
CandidateEndpoints = [CreateCandidateEndpoint("App1/css/app.css", Path.Combine("wwwroot", "css", "app.css"))]
};

// Act
var result = task.Execute();

// Assert
result.Should().Be(true);
task.Endpoints.Should().ContainSingle();
// Should remain "App1/css/app.css", not become "App1/App1/css/app.css"
task.Endpoints[0].ItemSpec.Should().Be("App1/css/app.css");
}

private static ITaskItem CreateCandidate(
string itemSpec,
string sourceId,
string sourceType,
string relativePath,
string assetKind,
string assetMode)
string assetMode,
string basePath = "base")
{
var result = new StaticWebAsset()
{
Identity = Path.GetFullPath(itemSpec),
SourceId = sourceId,
SourceType = sourceType,
ContentRoot = Directory.GetCurrentDirectory(),
BasePath = "base",
BasePath = basePath,
RelativePath = relativePath,
AssetKind = assetKind,
AssetMode = assetMode,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using Microsoft.AspNetCore.StaticWebAssets.Tasks;

namespace Microsoft.NET.Sdk.StaticWebAssets.Tests;

public class StaticWebAssetEndpointTest
{
[Theory]
[InlineData("App1/css/app.css", "App1")]
[InlineData("App1", "App1")]
[InlineData("App1/css/styles/app.css", "App1/css")]
[InlineData("App1/css/app.css", "")]
[InlineData("", "")]
[InlineData("App1\\css\\app.css", "App1")]
[InlineData("App1/css\\app.css", "App1")]
[InlineData("App1/App1.lib.module.js", "App1")]
[InlineData("app1/css/app.css", "App1")]
[InlineData("APP1/css/app.css", "app1")]
public void RouteHasPathPrefix_ReturnsTrue_WhenRouteStartsWithPrefixAsPathSegment(string route, string prefix)
{
var routeSegments = new List<PathTokenizer.Segment>();
var prefixSegments = new List<PathTokenizer.Segment>();

var result = StaticWebAssetEndpoint.RouteHasPathPrefix(route, prefix, routeSegments, prefixSegments);

result.Should().BeTrue();
}

[Theory]
[InlineData("App1.styles.css", "App1")]
[InlineData("App1", "App1/css/app.css")]
[InlineData("App1/js/app.js", "App1/css")]
[InlineData("App12/css/app.css", "App1")]
[InlineData("App1Bundle/app.js", "App1")]
[InlineData("App1.lib.module.js", "App1")]
public void RouteHasPathPrefix_ReturnsFalse_WhenRouteDoesNotStartWithPrefixAsPathSegment(string route, string prefix)
{
var routeSegments = new List<PathTokenizer.Segment>();
var prefixSegments = new List<PathTokenizer.Segment>();

var result = StaticWebAssetEndpoint.RouteHasPathPrefix(route, prefix, routeSegments, prefixSegments);

result.Should().BeFalse();
}

[Fact]
public void RouteHasPathPrefix_ReusesSegmentLists()
{
var routeSegments = new List<PathTokenizer.Segment>();
var prefixSegments = new List<PathTokenizer.Segment>();

StaticWebAssetEndpoint.RouteHasPathPrefix("a/b/c", "a", routeSegments, prefixSegments);
StaticWebAssetEndpoint.RouteHasPathPrefix("x/y/z", "x/y", routeSegments, prefixSegments);

var result = StaticWebAssetEndpoint.RouteHasPathPrefix("App1/css/app.css", "App1", routeSegments, prefixSegments);

result.Should().BeTrue();
}
}
Loading