diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2b187a949b6b..e5ca66cd0424 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,4 +15,16 @@ Localization: - Consider localizing strings in .resx files when possible. Documentation: -- Do not manually edit files under documentation/manpages/sdk as these are generated based on documentation and should not be manually modified. \ No newline at end of file +- Do not manually edit files under documentation/manpages/sdk as these are generated based on documentation and should not be manually modified. + +Benchmarking: +- Use BenchmarkDotNet for performance measurements with the [MemoryDiagnoser] attribute to track memory allocations. +- Run benchmarks before and after changes to demonstrate performance improvements. +- To run benchmarks: + ``` + cd src/StaticWebAssetsSdk/benchmarks + dotnet run --framework -c Release -- --filter "*MethodName*" + ``` +- Compare both throughput (ops/s) and memory allocations (bytes allocated) in benchmark results. +- Include benchmark results in PR descriptions when claiming performance improvements. +- Consider benchmarking on both .NET Framework and modern .NET when targeting multiple frameworks. diff --git a/.gitignore b/.gitignore index 21ad03fd2577..7e0f6b788a53 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,11 @@ cmake/ # Test results **/*.trx -/TestResults +**/TestResults /test/dotnet.Tests/CompletionTests/snapshots/**/**.received.* +# Benchmarks +**/BenchmarkDotNet.Artifacts/ + # Live Unit Testing *.lutconfig diff --git a/src/RazorSdk/Razor.slnf b/src/RazorSdk/Razor.slnf index 21dd975b0fc0..d619071eb790 100644 --- a/src/RazorSdk/Razor.slnf +++ b/src/RazorSdk/Razor.slnf @@ -16,7 +16,8 @@ "test\\Microsoft.NET.Sdk.BlazorWebAssembly.Tests\\Microsoft.NET.Sdk.BlazorWebAssembly.Tests.csproj", "test\\Microsoft.NET.Sdk.Razor.Tests\\Microsoft.NET.Sdk.Razor.Tests.csproj", "test\\Microsoft.NET.Sdk.Razor.Tool.Tests\\Microsoft.NET.Sdk.Razor.Tool.Tests.csproj", - "test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj" + "test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj", + "test\\Microsoft.NET.Sdk.StaticWebAssets.Tests\\Microsoft.NET.Sdk.StaticWebAssets.Tests.csproj" ] } } diff --git a/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs b/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs index 7bb893b62a37..6d6009bdc108 100644 --- a/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs +++ b/src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs @@ -5,6 +5,7 @@ using System.Globalization; using Microsoft.Build.Framework; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -19,6 +20,12 @@ public class ApplyCompressionNegotiation : Task [Output] public ITaskItem[] UpdatedEndpoints { get; set; } + private readonly List _selectorsList = []; + private readonly List _headersList = []; + private readonly List _tempHeadersList = []; + private readonly List _propertiesList = []; + private const int ExpectedCompressionHeadersCount = 2; + public override bool Execute() { var assetsById = StaticWebAsset.ToAssetDictionary(CandidateAssets); @@ -27,9 +34,23 @@ public override bool Execute() var updatedEndpoints = new HashSet(CandidateEndpoints.Length, StaticWebAssetEndpoint.RouteAndAssetComparer); - var compressionHeadersByEncoding = new Dictionary(2); + var compressionHeadersByEncoding = new Dictionary(ExpectedCompressionHeadersCount); + + using var jsonContext = new JsonWriterContext(); - // Add response headers to compressed endpoints + ProcessCompressedAssets(assetsById, endpointsByAsset, updatedEndpoints, compressionHeadersByEncoding, jsonContext); + AddRemainingEndpoints(endpointsByAsset, updatedEndpoints); + UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(updatedEndpoints); + return true; + } + + private void ProcessCompressedAssets( + Dictionary assetsById, + IDictionary> endpointsByAsset, + HashSet updatedEndpoints, + Dictionary compressionHeadersByEncoding, + JsonWriterContext jsonContext) + { foreach (var compressedAsset in assetsById.Values) { if (!string.Equals(compressedAsset.AssetTraitName, "Content-Encoding", StringComparison.Ordinal)) @@ -53,11 +74,11 @@ public override bool Execute() if (!HasContentEncodingResponseHeader(compressedEndpoint)) { - // Add the Content-Encoding and Vary headers - compressedEndpoint.ResponseHeaders = [ - ..compressedEndpoint.ResponseHeaders, - ..compressionHeaders - ]; + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList); + var currentCompressionHeaders = GetOrCreateCompressionHeaders(compressionHeadersByEncoding, compressedAsset); + _headersList.AddRange(currentCompressionHeaders); + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, jsonContext); + compressedEndpoint.SetResponseHeadersString(headersString); } var compressedHeaders = GetCompressedHeaders(compressedEndpoint); @@ -72,7 +93,7 @@ public override bool Execute() continue; } - var endpointCopy = CreateUpdatedEndpoint(compressedAsset, quality, compressedEndpoint, compressedHeaders, relatedEndpointCandidate); + var endpointCopy = CreateUpdatedEndpoint(compressedAsset, quality, compressedEndpoint, compressedHeaders, relatedEndpointCandidate, jsonContext); updatedEndpoints.Add(endpointCopy); // Since we are going to remove the endpoints from the associated item group and the route is // the ItemSpec, we want to add the original as well so that it gets re-added. @@ -82,19 +103,24 @@ public override bool Execute() } } } + } - // Before we return the updated endpoints we need to capture any other endpoint whose asset is not associated - // with the compressed asset. This is because we are going to remove the endpoints from the associated item group - // and the route is the ItemSpec, so it will cause those endpoints to be removed. - // For example, we have css/app.css and Link/css/app.css where Link=css/app.css and the first asset is a build asset - // and the second asset is a publish asset. - // If we are processing build assets, we'll mistakenly remove the endpoints associated with the publish asset. + // Before we return the updated endpoints we need to capture any other endpoint whose asset is not associated + // with the compressed asset. This is because we are going to remove the endpoints from the associated item group + // and the route is the ItemSpec, so it will cause those endpoints to be removed. + // For example, we have css/app.css and Link/css/app.css where Link=css/app.css and the first asset is a build asset + // and the second asset is a publish asset. + // If we are processing build assets, we'll mistakenly remove the endpoints associated with the publish asset. - // Iterate over the endpoints and find those endpoints whose route is in the set of updated endpoints but whose asset - // is not, and add them to the updated endpoints. + // Iterate over the endpoints and find those endpoints whose route is in the set of updated endpoints but whose asset + // is not, and add them to the updated endpoints. - // Reuse the map we created at the beginning. - // Remove all the endpoints that were updated to avoid adding them again. + // Reuse the map we created at the beginning. + // Remove all the endpoints that were updated to avoid adding them again. + private void AddRemainingEndpoints( + IDictionary> endpointsByAsset, + HashSet updatedEndpoints) + { foreach (var endpoint in updatedEndpoints) { if (endpointsByAsset.TryGetValue(endpoint.AssetFile, out var endpointsToSkip)) @@ -131,18 +157,16 @@ public override bool Execute() } updatedEndpoints.UnionWith(additionalUpdatedEndpoints); - - UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(updatedEndpoints); - - return true; } - private static HashSet GetCompressedHeaders(StaticWebAssetEndpoint compressedEndpoint) + private HashSet GetCompressedHeaders(StaticWebAssetEndpoint compressedEndpoint) { - var result = new HashSet(compressedEndpoint.ResponseHeaders.Length, StringComparer.Ordinal); - for (var i = 0; i < compressedEndpoint.ResponseHeaders.Length; i++) + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList); + + var result = new HashSet(_headersList.Count, StringComparer.Ordinal); + for (var i = 0; i < _headersList.Count; i++) { - var responseHeader = compressedEndpoint.ResponseHeaders[i]; + var responseHeader = _headersList[i]; result.Add(responseHeader.Name); } @@ -200,7 +224,8 @@ private StaticWebAssetEndpoint CreateUpdatedEndpoint( string quality, StaticWebAssetEndpoint compressedEndpoint, HashSet compressedHeaders, - StaticWebAssetEndpoint relatedEndpointCandidate) + StaticWebAssetEndpoint relatedEndpointCandidate, + JsonWriterContext jsonContext) { Log.LogMessage(MessageImportance.Low, "Processing related endpoint '{0}'", relatedEndpointCandidate.Route); var encodingSelector = new StaticWebAssetEndpointSelector @@ -210,31 +235,39 @@ private StaticWebAssetEndpoint CreateUpdatedEndpoint( Quality = quality }; Log.LogMessage(MessageImportance.Low, " Created Content-Encoding selector for compressed asset '{0}' with size '{1}' is '{2}'", encodingSelector.Value, encodingSelector.Quality, relatedEndpointCandidate.Route); + + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(relatedEndpointCandidate.SelectorsString, _selectorsList); + _selectorsList.Add(encodingSelector); + var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, jsonContext); + var endpointCopy = new StaticWebAssetEndpoint { AssetFile = compressedAsset.Identity, Route = relatedEndpointCandidate.Route, - Selectors = [ - ..relatedEndpointCandidate.Selectors, - encodingSelector - ], - EndpointProperties = relatedEndpointCandidate.EndpointProperties }; - var headers = new List(7); - ApplyCompressedEndpointHeaders(headers, compressedEndpoint, relatedEndpointCandidate.Route); - ApplyRelatedEndpointCandidateHeaders(headers, relatedEndpointCandidate, compressedHeaders); - endpointCopy.ResponseHeaders = [.. headers]; + + endpointCopy.SetSelectorsString(selectorsString); + endpointCopy.SetEndpointPropertiesString(relatedEndpointCandidate.EndpointPropertiesString); + + // Build headers using reusable list + _headersList.Clear(); + ApplyCompressedEndpointHeaders(_headersList, compressedEndpoint, relatedEndpointCandidate.Route); + ApplyRelatedEndpointCandidateHeaders(_headersList, relatedEndpointCandidate, compressedHeaders); + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, jsonContext); + endpointCopy.SetResponseHeadersString(headersString); // Update the endpoint Log.LogMessage(MessageImportance.Low, " Updated related endpoint '{0}' with Content-Encoding selector '{1}={2}'", relatedEndpointCandidate.Route, encodingSelector.Value, encodingSelector.Quality); return endpointCopy; } - private static bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint compressedEndpoint) + private bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint compressedEndpoint) { - for (var i = 0; i < compressedEndpoint.ResponseHeaders.Length; i++) + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList); + + for (var i = 0; i < _headersList.Count; i++) { - var responseHeader = compressedEndpoint.ResponseHeaders[i]; + var responseHeader = _headersList[i]; if (string.Equals(responseHeader.Name, "Content-Encoding", StringComparison.Ordinal)) { return true; @@ -244,11 +277,13 @@ private static bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint comp return false; } - private static bool HasContentEncodingSelector(StaticWebAssetEndpoint compressedEndpoint) + private bool HasContentEncodingSelector(StaticWebAssetEndpoint compressedEndpoint) { - for (var i = 0; i < compressedEndpoint.Selectors.Length; i++) + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(compressedEndpoint.SelectorsString, _selectorsList); + + for (var i = 0; i < _selectorsList.Count; i++) { - var selector = compressedEndpoint.Selectors[i]; + var selector = _selectorsList[i]; if (string.Equals(selector.Name, "Content-Encoding", StringComparison.Ordinal)) { return true; @@ -287,16 +322,18 @@ private static bool HasContentEncodingSelector(StaticWebAssetEndpoint compressed private static string ResolveQuality(StaticWebAsset compressedAsset) => Math.Round(1.0 / (compressedAsset.FileLength + 1), 12).ToString("F12", CultureInfo.InvariantCulture); - private static bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate) + private bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate) { - var compressedFingerprint = ResolveFingerprint(compressedEndpoint); - var relatedFingerprint = ResolveFingerprint(relatedEndpointCandidate); + var compressedFingerprint = ResolveFingerprint(compressedEndpoint, _propertiesList); + var relatedFingerprint = ResolveFingerprint(relatedEndpointCandidate, _propertiesList); return string.Equals(compressedFingerprint.Value, relatedFingerprint.Value, StringComparison.Ordinal); } - private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetEndpoint compressedEndpoint) + private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetEndpoint compressedEndpoint, List tempList) { - foreach (var property in compressedEndpoint.EndpointProperties) + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(compressedEndpoint.EndpointPropertiesString, tempList); + + foreach (var property in tempList) { if (string.Equals(property.Name, "fingerprint", StringComparison.Ordinal)) { @@ -308,7 +345,9 @@ private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetE private void ApplyCompressedEndpointHeaders(List headers, StaticWebAssetEndpoint compressedEndpoint, string relatedEndpointCandidateRoute) { - foreach (var header in compressedEndpoint.ResponseHeaders) + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _tempHeadersList); + + foreach (var header in _tempHeadersList) { if (string.Equals(header.Name, "Content-Type", StringComparison.Ordinal)) { @@ -326,7 +365,9 @@ private void ApplyCompressedEndpointHeaders(List headers, StaticWebAssetEndpoint relatedEndpointCandidate, HashSet compressedHeaders) { - foreach (var header in relatedEndpointCandidate.ResponseHeaders) + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(relatedEndpointCandidate.ResponseHeadersString, _tempHeadersList); + + foreach (var header in _tempHeadersList) { // We need to keep the headers that are specific to the compressed asset like Content-Length, // Last-Modified and ETag. Any other header we should add it. diff --git a/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs index b5b2915b2086..9e5764eba1f4 100644 --- a/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs @@ -24,6 +24,11 @@ public override bool Execute() var result = CandidateEndpoints; + // Reusable list for optimized endpoint property parsing + var endpointPropertiesList = new List(4); + + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + for (var i = 0; i < CandidateEndpoints.Length; i++) { var candidateEndpoint = StaticWebAssetEndpoint.FromTaskItem(CandidateEndpoints[i]); @@ -43,19 +48,30 @@ public override bool Execute() { candidateEndpoint.Route = StaticWebAsset.CombineNormalizedPaths("", asset.BasePath, candidateEndpoint.Route, '/'); - for (var j = 0; j < candidateEndpoint.EndpointProperties.Length; j++) + // Use optimized property parsing to avoid allocations + var endpointPropertiesString = CandidateEndpoints[i].GetMetadata(nameof(StaticWebAssetEndpoint.EndpointProperties)); + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpointPropertiesString, endpointPropertiesList); + + // Modify label properties in the reusable list + var propertiesModified = false; + for (var j = 0; j < endpointPropertiesList.Count; j++) { - ref var property = ref candidateEndpoint.EndpointProperties[j]; + var property = endpointPropertiesList[j]; if (string.Equals(property.Name, "label", StringComparison.OrdinalIgnoreCase)) { property.Value = StaticWebAsset.CombineNormalizedPaths("", asset.BasePath, property.Value, '/'); - // We need to do this because we are modifying the properties in place. - // We could instead do candidateEndpoint.EndpointProperties = candidateEndpoint.EndpointProperties - // but that's more obscure than this. - candidateEndpoint.MarkProperiesAsModified(); + endpointPropertiesList[j] = property; + propertiesModified = true; } } + if (propertiesModified) + { + // Serialize modified properties back using optimized method + candidateEndpoint.SetEndpointPropertiesString( + StaticWebAssetEndpointProperty.ToMetadataValue(endpointPropertiesList, context)); + } + Log.LogMessage(MessageImportance.Low, "Adding endpoint {0} for asset {1} with updated route {2}.", candidateEndpoint.Route, candidateEndpoint.AssetFile, candidateEndpoint.Route); result[i] = candidateEndpoint.ToTaskItem(); diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs index 80cfe3b4c417..cd7907b747dc 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpoint.cs @@ -60,7 +60,7 @@ public string AssetFile } } - private string SelectorsString + internal string SelectorsString { get { @@ -87,7 +87,7 @@ public StaticWebAssetEndpointSelector[] Selectors } } - private string ResponseHeadersString + internal string ResponseHeadersString { get { @@ -113,7 +113,7 @@ public StaticWebAssetEndpointResponseHeader[] ResponseHeaders } } - private string EndpointPropertiesString + internal string EndpointPropertiesString { get { @@ -145,6 +145,31 @@ internal void MarkProperiesAsModified() _endpointPropertiesModified = true; } + // Helper methods to set string properties directly while maintaining synchronization + internal void SetSelectorsString(string value) + { + _selectorsString = value; + _selectors = null; + _selectorsModified = false; + _modified = true; + } + + internal void SetResponseHeadersString(string value) + { + _responseHeadersString = value; + _responseHeaders = null; + _responseHeadersModified = false; + _modified = true; + } + + internal void SetEndpointPropertiesString(string value) + { + _endpointPropertiesString = value; + _endpointProperties = null; + _endpointPropertiesModified = false; + _modified = true; + } + public static IEqualityComparer RouteAndAssetComparer { get; } = new RouteAndAssetEqualityComparer(); internal static IDictionary> ToAssetFileDictionary(ITaskItem[] candidateEndpoints) @@ -279,7 +304,7 @@ private string GetDebuggerDisplay() => public int CompareTo(StaticWebAssetEndpoint other) { - var routeComparison = StringComparer.Ordinal.Compare(Route, Route); + var routeComparison = StringComparer.Ordinal.Compare(Route, other.Route); if (routeComparison != 0) { return routeComparison; diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointProperty.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointProperty.cs index 5110d7e36d54..af950cf18b47 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointProperty.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointProperty.cs @@ -3,9 +3,11 @@ #nullable disable +using System.Buffers; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -15,16 +17,146 @@ public struct StaticWebAssetEndpointProperty : IComparable _jsonTypeInfo = StaticWebAssetsJsonSerializerContext.Default.StaticWebAssetEndpointPropertyArray; + private static readonly Encoding _encoder = Encoding.UTF8; + + // Pre-encoded property names for high-performance serialization + private static readonly JsonEncodedText NamePropertyName = JsonEncodedText.Encode("Name"); + private static readonly JsonEncodedText ValuePropertyName = JsonEncodedText.Encode("Value"); + public string Name { get; set; } public string Value { get; set; } - internal static StaticWebAssetEndpointProperty[] FromMetadataValue(string value) => string.IsNullOrEmpty(value) ? [] : JsonSerializer.Deserialize(value, _jsonTypeInfo); + public static StaticWebAssetEndpointProperty[] FromMetadataValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return []; + } + + var result = JsonSerializer.Deserialize(value, _jsonTypeInfo); + Array.Sort(result); + return result; + } + + public static void PopulateFromMetadataValue(string value, List properties) + { + properties.Clear(); + + properties.Clear(); + if (string.IsNullOrEmpty(value)) + { + return; + } + + // Use stack allocation for small buffers, ArrayPool for larger ones to avoid heap allocation + var maxByteCount = _encoder.GetMaxByteCount(value.Length); + byte[] rentedBuffer = null; + +#if NET6_0_OR_GREATER + const int StackAllocThreshold = 1024; + Span bytes = maxByteCount <= StackAllocThreshold + ? stackalloc byte[maxByteCount] + : (rentedBuffer = ArrayPool.Shared.Rent(maxByteCount)).AsSpan(0, maxByteCount); + var actualByteCount = _encoder.GetBytes(value, bytes); + var reader = new Utf8JsonReader(bytes.Slice(0, actualByteCount)); +#else + // For .NET Framework and older versions, we always rent from the pool + byte[] bytes = rentedBuffer = ArrayPool.Shared.Rent(maxByteCount); + var actualByteCount = _encoder.GetBytes(value, 0, value.Length, bytes, 0); + var reader = new Utf8JsonReader(bytes.AsSpan(0, actualByteCount)); +#endif + + try + { + reader.Read(); // Move to start array + PopulateFromMetadataValue(ref reader, properties); + } + finally + { + if (rentedBuffer != null) + { + ArrayPool.Shared.Return(rentedBuffer); + } + } + } + + public static void PopulateFromMetadataValue(ref Utf8JsonReader reader, List properties) + { + // Expect to be positioned at start of array + if (reader.TokenType != JsonTokenType.StartArray) + { + reader.Read(); // Move to start array if not already there + } + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var property = new StaticWebAssetEndpointProperty(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.ValueTextEquals("Name"u8)) + { + reader.Read(); + // Try to get interned property name first using span comparison + var internedName = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(reader.ValueSpan); + property.Name = internedName ?? reader.GetString(); + } + else if (reader.ValueTextEquals("Value"u8)) + { + reader.Read(); + property.Value = reader.GetString(); + } + else + { + reader.Skip(); + } + } + + properties.Add(property); + } + } + + public static string ToMetadataValue(StaticWebAssetEndpointProperty[] responseHeaders) + { + var properties = responseHeaders ?? []; + Array.Sort(properties); + return JsonSerializer.Serialize(properties, _jsonTypeInfo); + } + + internal static string ToMetadataValue( + List properties, + JsonWriterContext context) + { + if (properties == null || properties.Count == 0) + { + return "[]"; + } + + // Reset the context and use deconstruct to get buffer and writer + context.Reset(); + var (buffer, writer) = context; - internal static string ToMetadataValue(StaticWebAssetEndpointProperty[] responseHeaders) => - JsonSerializer.Serialize( - responseHeaders ?? [], - _jsonTypeInfo); + writer.WriteStartArray(); + for (int i = 0; i < properties.Count; i++) + { + var property = properties[i]; + writer.WriteStartObject(); + writer.WritePropertyName(NamePropertyName); + writer.WriteStringValue(property.Name); + writer.WritePropertyName(ValuePropertyName); + writer.WriteStringValue(property.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.Flush(); + var (array, count) = buffer.GetArray(); + return _encoder.GetString(array, 0, count); + } + + internal static JsonWriterContext CreateWriter() + { + var context = new JsonWriterContext(); + return context; + } public int CompareTo(StaticWebAssetEndpointProperty other) => string.CompareOrdinal(Name, other.Name) switch { diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointResponseHeader.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointResponseHeader.cs index 8ca2af0a1f5b..36909e505e0e 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointResponseHeader.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointResponseHeader.cs @@ -3,9 +3,11 @@ #nullable disable +using System.Buffers; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -15,16 +17,155 @@ public struct StaticWebAssetEndpointResponseHeader : IEquatable _jsonTypeInfo = StaticWebAssetsJsonSerializerContext.Default.StaticWebAssetEndpointResponseHeaderArray; + private static readonly Encoding _encoder = Encoding.UTF8; + + // Pre-encoded property names for high-performance serialization + private static readonly JsonEncodedText NamePropertyName = JsonEncodedText.Encode("Name"); + private static readonly JsonEncodedText ValuePropertyName = JsonEncodedText.Encode("Value"); + public string Name { get; set; } public string Value { get; set; } - internal static StaticWebAssetEndpointResponseHeader[] FromMetadataValue(string value) => string.IsNullOrEmpty(value) ? [] : JsonSerializer.Deserialize(value, _jsonTypeInfo); + public static StaticWebAssetEndpointResponseHeader[] FromMetadataValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return []; + } + + var result = JsonSerializer.Deserialize(value, _jsonTypeInfo); + Array.Sort(result); + return result; + } + + public static void PopulateFromMetadataValue(string value, List headers) + { + headers.Clear(); + + if (string.IsNullOrEmpty(value)) + { + return; + } + + // Use stack allocation for small buffers, ArrayPool for larger ones to avoid heap allocation + var maxByteCount = _encoder.GetMaxByteCount(value.Length); + byte[] rentedBuffer = null; + +#if NET6_0_OR_GREATER + const int StackAllocThreshold = 1024; + Span bytes = maxByteCount <= StackAllocThreshold + ? stackalloc byte[maxByteCount] + : (rentedBuffer = ArrayPool.Shared.Rent(maxByteCount)).AsSpan(0, maxByteCount); + var actualByteCount = _encoder.GetBytes(value, bytes); + var reader = new Utf8JsonReader(bytes.Slice(0, actualByteCount)); +#else + var bytes = rentedBuffer = ArrayPool.Shared.Rent(maxByteCount); + var actualByteCount = _encoder.GetBytes(value, 0, value.Length, bytes, 0); + var reader = new Utf8JsonReader(bytes.AsSpan(0, actualByteCount)); +#endif + + try + { + PopulateFromMetadataValue(ref reader, headers); + } + finally + { + if (rentedBuffer != null) + { + ArrayPool.Shared.Return(rentedBuffer); + } + } + } + + public static void PopulateFromMetadataValue(ref Utf8JsonReader reader, List headers) + { + // Expect to be positioned at start of array + if (reader.TokenType != JsonTokenType.StartArray) + { + reader.Read(); // Move to start array if not already there + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var header = new StaticWebAssetEndpointResponseHeader(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.ValueTextEquals("Name"u8)) + { + reader.Read(); + // Try to get interned header name first using span comparison + var internedName = WellKnownResponseHeaders.TryGetInternedHeaderName(reader.ValueSpan); + header.Name = internedName ?? reader.GetString(); + } + else if (reader.ValueTextEquals("Value"u8)) + { + reader.Read(); + // Try to get interned header value first using span comparison + var internedValue = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(reader.ValueSpan); + header.Value = internedValue ?? reader.GetString(); + } + else + { + reader.Skip(); + } + } + + headers.Add(header); + } + } + + internal static string ToMetadataValue(StaticWebAssetEndpointResponseHeader[] responseHeaders) + { + var headers = responseHeaders ?? []; + Array.Sort(headers); + return JsonSerializer.Serialize(headers, _jsonTypeInfo); + } - internal static string ToMetadataValue(StaticWebAssetEndpointResponseHeader[] responseHeaders) => - JsonSerializer.Serialize( - responseHeaders ?? [], - _jsonTypeInfo); + internal static string ToMetadataValue( + List headers, + JsonWriterContext context) + { + if (headers == null || headers.Count == 0) + { + return "[]"; + } + + // Reset the context and use deconstruct to get buffer and writer + context.Reset(); + var (buffer, writer) = context; + + writer.WriteStartArray(); + for (var i = 0; i < headers.Count; i++) + { + var header = headers[i]; + writer.WriteStartObject(); + writer.WritePropertyName(NamePropertyName); + var preEncoded = WellKnownResponseHeaders.TryGetPreEncodedHeaderName(header.Name); + if (preEncoded.HasValue) + { + writer.WriteStringValue(preEncoded.Value); + } + else + { + writer.WriteStringValue(header.Name); + } + writer.WritePropertyName(ValuePropertyName); + writer.WriteStringValue(header.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.Flush(); + var (array, count) = buffer.GetArray(); + return _encoder.GetString(array, 0, count); + } + + internal static JsonWriterContext CreateWriter() + { + var context = new JsonWriterContext(); + return context; + } private string GetDebuggerDisplay() => $"{Name}: {Value}"; diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointSelector.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointSelector.cs index 91267095dda3..d81390494755 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointSelector.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointSelector.cs @@ -3,9 +3,11 @@ #nullable disable +using System.Buffers; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -15,18 +17,157 @@ public struct StaticWebAssetEndpointSelector : IEquatable _jsonTypeInfo = StaticWebAssetsJsonSerializerContext.Default.StaticWebAssetEndpointSelectorArray; + private static readonly Encoding _encoder = Encoding.UTF8; + + // Pre-encoded property names for high-performance serialization + private static readonly JsonEncodedText NamePropertyName = JsonEncodedText.Encode("Name"); + private static readonly JsonEncodedText ValuePropertyName = JsonEncodedText.Encode("Value"); + private static readonly JsonEncodedText QualityPropertyName = JsonEncodedText.Encode("Quality"); + public string Name { get; set; } public string Value { get; set; } public string Quality { get; set; } - public static StaticWebAssetEndpointSelector[] FromMetadataValue(string value) => string.IsNullOrEmpty(value) ? [] : JsonSerializer.Deserialize(value, _jsonTypeInfo); + public static StaticWebAssetEndpointSelector[] FromMetadataValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return []; + } + + var result = JsonSerializer.Deserialize(value, _jsonTypeInfo); + Array.Sort(result); + return result; + } + + public static void PopulateFromMetadataValue(string value, List selectors) + { + selectors.Clear(); + + if (string.IsNullOrEmpty(value)) + { + return; + } + + // Use stack allocation for small buffers, ArrayPool for larger ones to avoid heap allocation + var maxByteCount = _encoder.GetMaxByteCount(value.Length); + byte[] rentedBuffer = null; + +#if NET6_0_OR_GREATER + const int StackAllocThreshold = 1024; + Span bytes = maxByteCount <= StackAllocThreshold + ? stackalloc byte[maxByteCount] + : (rentedBuffer = ArrayPool.Shared.Rent(maxByteCount)).AsSpan(0, maxByteCount); + var actualByteCount = _encoder.GetBytes(value, bytes); + var reader = new Utf8JsonReader(bytes.Slice(0, actualByteCount)); +#else + byte[] bytes = rentedBuffer = ArrayPool.Shared.Rent(maxByteCount); + var actualByteCount = _encoder.GetBytes(value, 0, value.Length, bytes, 0); + var reader = new Utf8JsonReader(bytes.AsSpan(0, actualByteCount)); +#endif - public static string ToMetadataValue(StaticWebAssetEndpointSelector[] selectors) => - JsonSerializer.Serialize( - selectors ?? [], - _jsonTypeInfo); + try + { + reader.Read(); // Move to start array + PopulateFromMetadataValue(ref reader, selectors); + } + finally + { + if (rentedBuffer != null) + { + ArrayPool.Shared.Return(rentedBuffer); + } + } + } + + public static void PopulateFromMetadataValue(ref Utf8JsonReader reader, List selectors) + { + // Expect to be positioned at start of array + if (reader.TokenType != JsonTokenType.StartArray) + { + reader.Read(); // Move to start array if not already there + } + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var selector = new StaticWebAssetEndpointSelector(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.ValueTextEquals("Name"u8)) + { + reader.Read(); + // Try to get interned selector name first using span comparison + var internedName = WellKnownEndpointSelectorNames.TryGetInternedSelectorName(reader.ValueSpan); + selector.Name = internedName ?? reader.GetString(); + } + else if (reader.ValueTextEquals("Value"u8)) + { + reader.Read(); + // Try to get interned selector value first using span comparison + var internedValue = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(reader.ValueSpan); + selector.Value = internedValue ?? reader.GetString(); + } + else if (reader.ValueTextEquals("Quality"u8)) + { + reader.Read(); + selector.Quality = reader.GetString(); + } + else + { + reader.Skip(); + } + } + + selectors.Add(selector); + } + } + + public static string ToMetadataValue(StaticWebAssetEndpointSelector[] selectors) + { + var sortedSelectors = selectors ?? []; + Array.Sort(sortedSelectors); + return JsonSerializer.Serialize(sortedSelectors, _jsonTypeInfo); + } + + internal static string ToMetadataValue( + List selectors, + JsonWriterContext context) + { + if (selectors == null || selectors.Count == 0) + { + return "[]"; + } + + // Reset the context and use deconstruct to get buffer and writer + context.Reset(); + var (buffer, writer) = context; + + writer.WriteStartArray(); + for (int i = 0; i < selectors.Count; i++) + { + var selector = selectors[i]; + writer.WriteStartObject(); + writer.WritePropertyName(NamePropertyName); + writer.WriteStringValue(selector.Name); + writer.WritePropertyName(ValuePropertyName); + writer.WriteStringValue(selector.Value); + writer.WritePropertyName(QualityPropertyName); + writer.WriteStringValue(selector.Quality); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.Flush(); + + var (array, count) = buffer.GetArray(); + return _encoder.GetString(array, 0, count); + } + + internal static JsonWriterContext CreateWriter() + { + var context = new JsonWriterContext(); + return context; + } public int CompareTo(StaticWebAssetEndpointSelector other) { diff --git a/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointPropertyNames.cs b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointPropertyNames.cs new file mode 100644 index 000000000000..72558ead48b4 --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointPropertyNames.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +internal static class WellKnownEndpointPropertyNames +{ + // Common endpoint property names + public const string Label = "label"; + public const string Integrity = "integrity"; + + /// + /// Gets the interned property name if it's a well-known property name, otherwise returns null. + /// Uses span comparison for efficiency. + /// + /// The property name span to check + /// The interned property name or null if not well-known + public static string TryGetInternedPropertyName(ReadOnlySpan propertyNameSpan) + { + return propertyNameSpan.Length switch + { + 5 => propertyNameSpan.SequenceEqual("label"u8) ? Label : null, + 9 => propertyNameSpan.SequenceEqual("integrity"u8) ? Integrity : null, + _ => null + }; + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointSelectorNames.cs b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointSelectorNames.cs new file mode 100644 index 000000000000..9ddc74af026e --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointSelectorNames.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +internal static class WellKnownEndpointSelectorNames +{ + // Common endpoint selector names + public const string ContentEncoding = "Content-Encoding"; + + /// + /// Gets the interned selector name if it's a well-known selector name, otherwise returns null. + /// Uses span comparison for efficiency. + /// + /// The selector name span to check + /// The interned selector name or null if not well-known + public static string TryGetInternedSelectorName(ReadOnlySpan selectorNameSpan) + { + return (selectorNameSpan.Length switch + { + 16 => selectorNameSpan.SequenceEqual("Content-Encoding"u8) ? ContentEncoding : null, + _ => null + }); + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointSelectorValues.cs b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointSelectorValues.cs new file mode 100644 index 000000000000..4a768f6164f9 --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownEndpointSelectorValues.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +internal static class WellKnownEndpointSelectorValues +{ + // Common selector values for Content-Encoding + public const string Gzip = "gzip"; + public const string Brotli = "br"; + + /// + /// Gets the interned selector value if it's a well-known value, otherwise returns null. + /// Uses span comparison for efficiency. + /// + /// The selector value span to check + /// The interned selector value or null if not well-known + public static string TryGetInternedSelectorValue(ReadOnlySpan selectorValueSpan) + { + return (selectorValueSpan.Length switch + { + 2 => selectorValueSpan.SequenceEqual("br"u8) ? Brotli : null, + 4 => selectorValueSpan.SequenceEqual("gzip"u8) ? Gzip : null, + _ => null + }); + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Data/WellKnownResponseHeaderValues.cs b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownResponseHeaderValues.cs new file mode 100644 index 000000000000..03ef8fe4927a --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownResponseHeaderValues.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +internal static class WellKnownResponseHeaderValues +{ + // Accept-Ranges values + public const string Bytes = "bytes"; + + // Cache-Control values + public const string NoCache = "no-cache"; + public const string MaxAgeImmutable = "max-age=31536000, immutable"; + + // Content-Encoding values + public const string Gzip = "gzip"; + public const string Brotli = "br"; + + // Content-Type values (common ones) + public const string ApplicationOctetStream = "application/octet-stream"; + public const string TextJavascript = "text/javascript"; + public const string TextCss = "text/css"; + public const string TextHtml = "text/html"; + public const string ApplicationJson = "application/json"; + public const string ImagePng = "image/png"; + public const string ImageJpeg = "image/jpeg"; + public const string ImageSvg = "image/svg+xml"; + + // Vary values + public const string ContentEncoding = "Content-Encoding"; + /// + /// Gets the interned header value if it's a well-known value, otherwise returns the original string. + /// Uses span comparison for efficiency. + /// + /// The header value span to check + /// The interned header value or null if not well-known + public static string TryGetInternedHeaderValue(ReadOnlySpan headerValueSpan) + { + return headerValueSpan.Length switch + { + 2 => headerValueSpan.SequenceEqual("br"u8) ? Brotli : null, + 4 => headerValueSpan.SequenceEqual("gzip"u8) ? Gzip : null, + 5 => headerValueSpan.SequenceEqual("bytes"u8) ? Bytes : null, + 8 => headerValueSpan[0] switch + { + (byte)'n' when headerValueSpan.SequenceEqual("no-cache"u8) => NoCache, + (byte)'t' when headerValueSpan.SequenceEqual("text/css"u8) => TextCss, + _ => null + }, + 9 => headerValueSpan[0] switch + { + (byte)'t' when headerValueSpan.SequenceEqual("text/html"u8) => TextHtml, + (byte)'i' when headerValueSpan.SequenceEqual("image/png"u8) => ImagePng, + _ => null + }, + 10 => headerValueSpan.SequenceEqual("image/jpeg"u8) ? ImageJpeg : null, + 13 => headerValueSpan.SequenceEqual("image/svg+xml"u8) ? ImageSvg : null, + 15 => headerValueSpan.SequenceEqual("text/javascript"u8) ? TextJavascript : null, + 16 => headerValueSpan[0] switch + { + (byte)'a' when headerValueSpan.SequenceEqual("application/json"u8) => ApplicationJson, + (byte)'C' when headerValueSpan.SequenceEqual("Content-Encoding"u8) => ContentEncoding, + _ => null + }, + 24 => headerValueSpan.SequenceEqual("application/octet-stream"u8) ? ApplicationOctetStream : null, + 27 => headerValueSpan.SequenceEqual("max-age=31536000, immutable"u8) ? MaxAgeImmutable : null, + _ => null + }; + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Data/WellKnownResponseHeaders.cs b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownResponseHeaders.cs new file mode 100644 index 000000000000..238d9eab36ab --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Data/WellKnownResponseHeaders.cs @@ -0,0 +1,77 @@ +// 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 System.Text.Json; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +internal static class WellKnownResponseHeaders +{ + // Header names from the sample payload + public const string AcceptRanges = "Accept-Ranges"; + public const string CacheControl = "Cache-Control"; + public const string ContentEncoding = "Content-Encoding"; + public const string ContentLength = "Content-Length"; + public const string ContentType = "Content-Type"; + public const string ETag = "ETag"; + public const string LastModified = "Last-Modified"; + public const string Vary = "Vary"; + + // Pre-encoded property names for high-performance JSON serialization + public static readonly JsonEncodedText AcceptRangesPropertyName = JsonEncodedText.Encode(AcceptRanges); + public static readonly JsonEncodedText CacheControlPropertyName = JsonEncodedText.Encode(CacheControl); + public static readonly JsonEncodedText ContentEncodingPropertyName = JsonEncodedText.Encode(ContentEncoding); + public static readonly JsonEncodedText ContentLengthPropertyName = JsonEncodedText.Encode(ContentLength); + public static readonly JsonEncodedText ContentTypePropertyName = JsonEncodedText.Encode(ContentType); + public static readonly JsonEncodedText ETagPropertyName = JsonEncodedText.Encode(ETag); + public static readonly JsonEncodedText LastModifiedPropertyName = JsonEncodedText.Encode(LastModified); + public static readonly JsonEncodedText VaryPropertyName = JsonEncodedText.Encode(Vary); + + public static string TryGetInternedHeaderName(ReadOnlySpan headerNameSpan) + { + return headerNameSpan.Length switch + { + 4 => headerNameSpan[0] switch + { + (byte)'E' when headerNameSpan.SequenceEqual("ETag"u8) => ETag, + (byte)'V' when headerNameSpan.SequenceEqual("Vary"u8) => Vary, + _ => null + }, + 12 => headerNameSpan.SequenceEqual("Content-Type"u8) ? ContentType : null, + 13 => headerNameSpan[0] switch + { + (byte)'A' when headerNameSpan.SequenceEqual("Accept-Ranges"u8) => AcceptRanges, + (byte)'C' when headerNameSpan.SequenceEqual("Cache-Control"u8) => CacheControl, + (byte)'L' when headerNameSpan.SequenceEqual("Last-Modified"u8) => LastModified, + _ => null + }, + 14 => headerNameSpan.SequenceEqual("Content-Length"u8) ? ContentLength : null, + 16 => headerNameSpan.SequenceEqual("Content-Encoding"u8) ? ContentEncoding : null, + _ => null + }; + } + + public static JsonEncodedText? TryGetPreEncodedHeaderName(string name) => + name?.Length switch + { + 4 => name[0] switch + { + 'E' when string.Equals(name, ETag, StringComparison.Ordinal) => ETagPropertyName, + 'V' when string.Equals(name, Vary, StringComparison.Ordinal) => VaryPropertyName, + _ => null + }, + 12 when string.Equals(name, ContentType, StringComparison.Ordinal) => ContentTypePropertyName, + 13 => name[0] switch + { + 'A' when string.Equals(name, AcceptRanges, StringComparison.Ordinal) => AcceptRangesPropertyName, + 'C' when string.Equals(name, CacheControl, StringComparison.Ordinal) => CacheControlPropertyName, + 'L' when string.Equals(name, LastModified, StringComparison.Ordinal) => LastModifiedPropertyName, + _ => null + }, + 14 when string.Equals(name, ContentLength, StringComparison.Ordinal) => ContentLengthPropertyName, + 16 when string.Equals(name, ContentEncoding, StringComparison.Ordinal) => ContentEncodingPropertyName, + _ => null + }; +} diff --git a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs index 47e1256ed636..4c8f5ae5d95e 100644 --- a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs +++ b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssetEndpoints.cs @@ -39,7 +39,11 @@ public override bool Execute() Log, contentTypeProvider), static (i, loop, state) => state.Process(i, loop), - static worker => worker.Finally()); + static (worker) => + { + worker.Finally(); + worker.Dispose(); + }); Endpoints = StaticWebAssetEndpoint.ToTaskItems(endpoints); @@ -116,6 +120,11 @@ private readonly struct ParallelWorker( private readonly List _resolvedRoutes = new(2); + private readonly List _headersList = new(6); + private readonly List _propertiesList = new(10); + + private readonly JsonWriterContext _serializationContext = new(); + private void CreateAnAddEndpoints( StaticWebAsset asset, string length, @@ -125,55 +134,32 @@ private void CreateAnAddEndpoints( foreach (var (label, route, values) in _resolvedRoutes) { var (mimeType, cacheSetting) = ResolveContentType(asset, ContentTypeProvider, matchContext, Log); - var headers = new StaticWebAssetEndpointResponseHeader[6] - { - new() - { - Name = "Accept-Ranges", - Value = "bytes" - }, - new() - { - Name = "Content-Length", - Value = length, - }, - new() - { - Name = "Content-Type", - Value = mimeType, - }, - new() - { - Name = "ETag", - Value = $"\"{asset.Integrity}\"", - }, - new() - { - Name = "Last-Modified", - Value = lastModified, - }, - default - }; + + _headersList.Clear(); + _headersList.Add(new() { Name = "Accept-Ranges", Value = "bytes" }); + _headersList.Add(new() { Name = "Content-Length", Value = length }); + _headersList.Add(new() { Name = "Content-Type", Value = mimeType }); + _headersList.Add(new() { Name = "ETag", Value = $"\"{asset.Integrity}\"" }); + _headersList.Add(new() { Name = "Last-Modified", Value = lastModified }); if (values.ContainsKey("fingerprint")) { // max-age=31536000 is one year in seconds. immutable means that the asset will never change. // max-age is for browsers that do not support immutable. - headers[5] = new() { Name = "Cache-Control", Value = "max-age=31536000, immutable" }; + _headersList.Add(new() { Name = "Cache-Control", Value = "max-age=31536000, immutable" }); } else { // Force revalidation on non-fingerprinted assets. We can be more granular here and have rules based on the content type. // These values can later be changed at runtime by modifying the endpoint. For example, it might be safer to cache images // for a longer period of time than scripts or stylesheets. - headers[5] = new() { Name = "Cache-Control", Value = !string.IsNullOrEmpty(cacheSetting) ? cacheSetting : "no-cache" }; + _headersList.Add(new() { Name = "Cache-Control", Value = !string.IsNullOrEmpty(cacheSetting) ? cacheSetting : "no-cache" }); } - var properties = new StaticWebAssetEndpointProperty[values.Count + (values.Count > 0 ? 2 : 1)]; - var i = 0; + _propertiesList.Clear(); foreach (var value in values) { - properties[i++] = new StaticWebAssetEndpointProperty { Name = value.Key, Value = value.Value }; + _propertiesList.Add(new StaticWebAssetEndpointProperty { Name = value.Key, Value = value.Value }); } if (values.Count > 0) @@ -182,23 +168,27 @@ private void CreateAnAddEndpoints( // The combination of label and list of values should be unique. // In this way, we can identify an endpoint resource.fingerprint.ext by its label (for example resource.ext) and its values // (fingerprint). - properties[i++] = new StaticWebAssetEndpointProperty { Name = "label", Value = label }; + _propertiesList.Add(new StaticWebAssetEndpointProperty { Name = "label", Value = label }); } // We append the integrity in the format expected by the browser so that it can be opaque to the runtime. // If in the future we change it to sha384 or sha512, the runtime will not need to be updated. - properties[i++] = new StaticWebAssetEndpointProperty { Name = "integrity", Value = $"sha256-{asset.Integrity}" }; + _propertiesList.Add(new StaticWebAssetEndpointProperty { Name = "integrity", Value = $"sha256-{asset.Integrity}" }); - var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route; + var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route; + + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, _serializationContext); + var propertiesString = StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesList, _serializationContext); var endpoint = new StaticWebAssetEndpoint() { Route = finalRoute, AssetFile = asset.Identity, - EndpointProperties = properties, - ResponseHeaders = headers }; + endpoint.SetResponseHeadersString(headersString); + endpoint.SetEndpointPropertiesString(propertiesString); + Log.LogMessage(MessageImportance.Low, $"Adding endpoint {endpoint.Route} for asset {asset.Identity}."); CurrentEndpoints.Add(endpoint); } @@ -229,6 +219,11 @@ internal void Finally() } } + public void Dispose() + { + _serializationContext.Dispose(); + } + internal ParallelWorker Process(int i, ParallelLoopState _) { var asset = StaticWebAsset.FromTaskItem(CandidateAssets[i]); diff --git a/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs index 5ddbd12f8530..3aa0dc8e4d5a 100644 --- a/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs +++ b/src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetEndpoints.cs @@ -29,9 +29,15 @@ public class FilterStaticWebAssetEndpoints : Task [Output] public ITaskItem[] AssetsWithoutMatchingEndpoints { get; set; } + // Private fields for reusable collections and criteria + private FilterCriteria[] _filterCriteria; + private readonly List _propertiesList = new(); + private readonly List _selectorsList = new(); + private readonly List _headersList = new(); + public override bool Execute() { - var filterCriteria = (Filters ?? []).Select(FilterCriteria.FromTaskItem).ToArray(); + _filterCriteria = (Filters ?? []).Select(FilterCriteria.FromTaskItem).ToArray(); var assetFiles = Assets != null ? StaticWebAsset.ToAssetDictionary(Assets) : []; var endpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints ?? []); var endpointFoundMatchingAsset = new Dictionary(); @@ -46,7 +52,7 @@ public override bool Execute() continue; } - if (MeetsAllCriteria(endpoint, asset, filterCriteria, out var failingCriteria)) + if (MeetsAllCriteria(endpoint, asset, out var failingCriteria)) { if (asset != null && !endpointFoundMatchingAsset.ContainsKey(asset.Identity)) { @@ -73,18 +79,24 @@ public override bool Execute() return !Log.HasLoggedErrors; } - private static bool MeetsAllCriteria(StaticWebAssetEndpoint endpoint, StaticWebAsset asset, FilterCriteria[] filterCriteria, out FilterCriteria failingCriteria) + private bool MeetsAllCriteria( + StaticWebAssetEndpoint endpoint, + StaticWebAsset asset, + out FilterCriteria failingCriteria) { - for (var i = 0; i < filterCriteria.Length; i++) + for (var i = 0; i < _filterCriteria.Length; i++) { - var criteria = filterCriteria[i]; + var criteria = _filterCriteria[i]; switch (criteria.Type) { case "Property": var meetsPropertyCriteria = criteria.ExcludeOnMatch(); - for (var j = 0; j < endpoint.EndpointProperties.Length; j++) + var propertiesString = endpoint.EndpointPropertiesString; + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(propertiesString, _propertiesList); + + for (var j = 0; j < _propertiesList.Count; j++) { - var property = endpoint.EndpointProperties[j]; + var property = _propertiesList[j]; if (MeetsCriteria(criteria, property.Name, property.Value)) { meetsPropertyCriteria = !criteria.ExcludeOnMatch(); @@ -99,9 +111,12 @@ private static bool MeetsAllCriteria(StaticWebAssetEndpoint endpoint, StaticWebA break; case "Selector": var meetsSelectorCriteria = criteria.ExcludeOnMatch(); - for (var j = 0; j < endpoint.Selectors.Length; j++) + var selectorsString = endpoint.SelectorsString; + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(selectorsString, _selectorsList); + + for (var j = 0; j < _selectorsList.Count; j++) { - var selector = endpoint.Selectors[j]; + var selector = _selectorsList[j]; if (MeetsCriteria(criteria, selector.Name, selector.Value)) { meetsSelectorCriteria = !criteria.ExcludeOnMatch(); @@ -116,9 +131,12 @@ private static bool MeetsAllCriteria(StaticWebAssetEndpoint endpoint, StaticWebA break; case "Header": var meetsHeaderCriteria = criteria.ExcludeOnMatch(); - for (var j = 0; j < endpoint.ResponseHeaders.Length; j++) + var headersString = endpoint.ResponseHeadersString; + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(headersString, _headersList); + + for (var j = 0; j < _headersList.Count; j++) { - var header = endpoint.ResponseHeaders[j]; + var header = _headersList[j]; if (MeetsCriteria(criteria, header.Name, header.Value)) { meetsHeaderCriteria = !criteria.ExcludeOnMatch(); diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs index 071f42ac285d..407857ba38c0 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsPropsFile.cs @@ -51,12 +51,16 @@ private bool ExecuteCore(StaticWebAssetEndpoint[] endpoints, Dictionary a.Identity, a => a, OSPath.PathComparer); var filteredEndpoints = new List(); - foreach (var endpoint in Endpoints.Select(StaticWebAssetEndpoint.FromTaskItem)) + // Use optimized filtering that only accesses necessary properties + for (var i = 0; i < Endpoints.Length; i++) { - if (assetsByIdentity.ContainsKey(endpoint.AssetFile)) + var endpointItem = Endpoints[i]; + var assetFile = endpointItem.GetMetadata(nameof(StaticWebAssetEndpoint.AssetFile)); + + if (assetsByIdentity.ContainsKey(assetFile)) { + // Only create the full endpoint object for endpoints that pass the filter + var endpoint = StaticWebAssetEndpoint.FromTaskItem(endpointItem); filteredEndpoints.Add(endpoint); Log.LogMessage(MessageImportance.Low, $"Accepted endpoint: Route='{endpoint.Route}', AssetFile='{endpoint.AssetFile}'"); } else { - Log.LogMessage(MessageImportance.Low, $"Filtered out endpoint: Endpoint='{endpoint.Route}' AssetFile='{endpoint.AssetFile}'"); + var route = endpointItem.ItemSpec; + Log.LogMessage(MessageImportance.Low, $"Filtered out endpoint: Endpoint='{route}' AssetFile='{assetFile}'"); } } diff --git a/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj b/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj index 1d3be9039972..19b50b2bd405 100644 --- a/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj +++ b/src/StaticWebAssetsSdk/Tasks/Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj @@ -44,6 +44,7 @@ + diff --git a/src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs b/src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs index eaa0dbdde404..6e5d2cbf82ee 100644 --- a/src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs +++ b/src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs @@ -43,6 +43,10 @@ public partial class OverrideHtmlAssetPlaceholders : Task internal static readonly Regex _preloadRegex = new Regex(@"[^""]+)"")?\s*[/]?>"); + // Reusable collections to avoid allocations + private readonly List _propertiesList = new(10); + private readonly List _selectorsList = new(4); + public override bool Execute() { var endpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints).Where(e => e.AssetFile.EndsWith(".js") || e.AssetFile.EndsWith(".mjs")); @@ -172,12 +176,18 @@ internal List CreateResourcesFromEndpoints(IEnumerable _propertiesList = new(10); + public override bool Execute() { var candidateEndpoints = StaticWebAssetEndpoint.FromItemGroup(CandidateEndpoints); @@ -96,11 +99,14 @@ public override bool Execute() return !Log.HasLoggedErrors; } - private static bool HasFingerprint(StaticWebAssetEndpoint endpoint) + private bool HasFingerprint(StaticWebAssetEndpoint endpoint) { - for (var i = 0; i < endpoint.EndpointProperties.Length; i++) + // Use the reusable list to avoid allocations + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpoint.EndpointPropertiesString, _propertiesList); + + for (var i = 0; i < _propertiesList.Count; i++) { - var property = endpoint.EndpointProperties[i]; + var property = _propertiesList[i]; if (string.Equals(property.Name, "fingerprint", StringComparison.OrdinalIgnoreCase)) { return true; diff --git a/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs b/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs index d2d1bf27c38f..aaf887805cb7 100644 --- a/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs +++ b/src/StaticWebAssetsSdk/Tasks/UpdateStaticWebAssetEndpoints.cs @@ -4,6 +4,7 @@ #nullable disable using Microsoft.Build.Framework; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -39,8 +40,16 @@ public class UpdateStaticWebAssetEndpoints : Task [Output] public ITaskItem[] UpdatedEndpoints { get; set; } + // Reusable collections to avoid allocations + private readonly List _selectorsList = new(4); + private readonly List _headersList = new(8); + private readonly List _propertiesList = new(8); + private JsonWriterContext _serializationContext; + public override bool Execute() { + _serializationContext = new JsonWriterContext(); + var endpointsToUpdate = StaticWebAssetEndpoint.FromItemGroup(EndpointsToUpdate) .GroupBy(e => e.Route) .ToDictionary(e => e.Key, e => e.ToHashSet()); @@ -79,10 +88,11 @@ public override bool Execute() UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(result); + _serializationContext.Dispose(); return !Log.HasLoggedErrors; } - private static bool TryUpdateEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation[] operations, List result) + private bool TryUpdateEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation[] operations, List result) { var updated = false; for (var i = 0; i < operations.Length; i++) @@ -116,31 +126,34 @@ private static bool TryUpdateEndpoint(StaticWebAssetEndpoint endpoint, StaticWeb return updated; } - private static bool RemoveAllFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) + private bool RemoveAllFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) { switch (operation.Target) { case "Selector": - var (selectors, selectorRemoved) = RemoveAllIfFound(endpoint.Selectors, s => s.Name, s => s.Value, operation.Name, operation.Value); - if (selectorRemoved) + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(endpoint.SelectorsString, _selectorsList); + if (RemoveAllIfFound(_selectorsList, s => s.Name, s => s.Value, operation.Name, operation.Value)) { - endpoint.Selectors = selectors; + var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, _serializationContext); + endpoint.SetSelectorsString(selectorsString); return true; } break; case "Header": - var (headers, headerRemoved) = RemoveAllIfFound(endpoint.ResponseHeaders, h => h.Name, h => h.Value, operation.Name, operation.Value); - if (headerRemoved) + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(endpoint.ResponseHeadersString, _headersList); + if (RemoveAllIfFound(_headersList, h => h.Name, h => h.Value, operation.Name, operation.Value)) { - endpoint.ResponseHeaders = headers; + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, _serializationContext); + endpoint.SetResponseHeadersString(headersString); return true; } break; case "Property": - var (properties, propertyRemoved) = RemoveAllIfFound(endpoint.EndpointProperties, p => p.Name, p => p.Value, operation.Name, operation.Value); - if (propertyRemoved) + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpoint.EndpointPropertiesString, _propertiesList); + if (RemoveAllIfFound(_propertiesList, p => p.Name, p => p.Value, operation.Name, operation.Value)) { - endpoint.EndpointProperties = properties; + var propertiesString = StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesList, _serializationContext); + endpoint.SetEndpointPropertiesString(propertiesString); return true; } break; @@ -151,98 +164,85 @@ private static bool RemoveAllFromEndpoint(StaticWebAssetEndpoint endpoint, Stati return false; } - private static (T[], bool replaced) RemoveAllIfFound(T[] elements, Func getName, Func getValue, string name, string value) + private static bool RemoveAllIfFound(List elements, Func getName, Func getValue, string name, string value) { - List selectors = null; - for (var i = 0; i < elements.Length; i++) + var removed = false; + for (var i = elements.Count - 1; i >= 0; i--) { if (string.Equals(getName(elements[i]), name, StringComparison.OrdinalIgnoreCase) && (string.IsNullOrEmpty(value) || string.Equals(getValue(elements[i]), value, StringComparison.Ordinal))) { - if (selectors == null) - { - selectors = []; - for (var j = 0; j < i; j++) - { - selectors.Add(elements[j]); - } - } + elements.RemoveAt(i); + removed = true; } - else - { - selectors?.Add(elements[i]); - } - } - if (selectors != null) - { - return ([.. selectors], true); } - - return (elements, false); + return removed; } - private static (T[], bool replaced) RemoveFirstIfFound(T[] elements, Func getName, Func getValue, string name, string value) + private static bool RemoveFirstIfFound(List elements, Func getName, Func getValue, string name, string value) { - for (var i = 0; i < elements.Length; i++) + for (var i = 0; i < elements.Count; i++) { if (string.Equals(getName(elements[i]), name, StringComparison.OrdinalIgnoreCase) && (string.IsNullOrEmpty(value) || string.Equals(getValue(elements[i]), value, StringComparison.Ordinal))) { - var prefix = elements.Take(i); - var suffix = prefix.Skip(1); - return ([.. prefix, .. suffix], true); + elements.RemoveAt(i); + return true; } } - return (elements, false); + return false; } - private static bool ReplaceInEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) + private bool ReplaceInEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) { switch (operation.Target) { case "Selector": - var (selectors, selectorReplaced) = ReplaceFirstIfFound( - endpoint.Selectors, + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(endpoint.SelectorsString, _selectorsList); + if (ReplaceFirstIfFound( + _selectorsList, s => s.Name, s => s.Value, (name, value) => new StaticWebAssetEndpointSelector { Name = name, Value = value, Quality = operation.Quality }, operation.Name, operation.Value, - operation.NewValue); - if (selectorReplaced) + operation.NewValue)) { - endpoint.Selectors = selectors; + var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, _serializationContext); + endpoint.SetSelectorsString(selectorsString); return true; } break; case "Header": - var (headers, headerReplaced) = ReplaceFirstIfFound( - endpoint.ResponseHeaders, + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(endpoint.ResponseHeadersString, _headersList); + if (ReplaceFirstIfFound( + _headersList, h => h.Name, h => h.Value, (name, value) => new StaticWebAssetEndpointResponseHeader { Name = name, Value = value }, operation.Name, operation.Value, - operation.NewValue); - if (headerReplaced) + operation.NewValue)) { - endpoint.ResponseHeaders = headers; + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, _serializationContext); + endpoint.SetResponseHeadersString(headersString); return true; } break; case "Property": - var (properties, propertyReplaced) = ReplaceFirstIfFound( - endpoint.EndpointProperties, + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpoint.EndpointPropertiesString, _propertiesList); + if (ReplaceFirstIfFound( + _propertiesList, p => p.Name, p => p.Value, (name, value) => new StaticWebAssetEndpointProperty { Name = name, Value = value }, operation.Name, operation.Value, - operation.NewValue); - if (propertyReplaced) + operation.NewValue)) { - endpoint.EndpointProperties = properties; + var propertiesString = StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesList, _serializationContext); + endpoint.SetEndpointPropertiesString(propertiesString); return true; } break; @@ -253,52 +253,54 @@ private static bool ReplaceInEndpoint(StaticWebAssetEndpoint endpoint, StaticWeb return false; } - private static (T[], bool replaced) ReplaceFirstIfFound( - T[] elements, + private static bool ReplaceFirstIfFound( + List elements, Func getName, Func getValue, Func createNew, string name, string value, string newValue) { - for (var i = 0; i < elements.Length; i++) + for (var i = 0; i < elements.Count; i++) { if (string.Equals(getName(elements[i]), name, StringComparison.OrdinalIgnoreCase) && (string.IsNullOrEmpty(value) || string.Equals(getValue(elements[i]), value, StringComparison.Ordinal))) { - var prefix = elements.Take(i); - var suffix = elements.Skip(i + 1); - return ([.. prefix, createNew(name, newValue), .. suffix], true); + elements[i] = createNew(name, newValue); + return true; } } - return (elements, false); + return false; } - private static bool RemoveFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) + private bool RemoveFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) { switch (operation.Target) { case "Selector": - var (selectors, selectorRemoved) = RemoveFirstIfFound(endpoint.Selectors, s => s.Name, s => s.Value, operation.Name, operation.Value); - if (selectorRemoved) + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(endpoint.SelectorsString, _selectorsList); + if (RemoveFirstIfFound(_selectorsList, s => s.Name, s => s.Value, operation.Name, operation.Value)) { - endpoint.Selectors = selectors; + var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, _serializationContext); + endpoint.SetSelectorsString(selectorsString); return true; } break; case "Header": - var (headers, headerRemoved) = RemoveFirstIfFound(endpoint.ResponseHeaders, h => h.Name, h => h.Value, operation.Name, operation.Value); - if (headerRemoved) + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(endpoint.ResponseHeadersString, _headersList); + if (RemoveFirstIfFound(_headersList, h => h.Name, h => h.Value, operation.Name, operation.Value)) { - endpoint.ResponseHeaders = headers; + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, _serializationContext); + endpoint.SetResponseHeadersString(headersString); return true; } break; case "Property": - var (properties, propertyRemoved) = RemoveFirstIfFound(endpoint.EndpointProperties, p => p.Name, p => p.Value, operation.Name, operation.Value); - if (propertyRemoved) + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpoint.EndpointPropertiesString, _propertiesList); + if (RemoveFirstIfFound(_propertiesList, p => p.Name, p => p.Value, operation.Name, operation.Value)) { - endpoint.EndpointProperties = properties; + var propertiesString = StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesList, _serializationContext); + endpoint.SetEndpointPropertiesString(propertiesString); return true; } break; @@ -309,37 +311,40 @@ private static bool RemoveFromEndpoint(StaticWebAssetEndpoint endpoint, StaticWe return false; } - private static void AppendToEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) + private void AppendToEndpoint(StaticWebAssetEndpoint endpoint, StaticWebAssetEndpointOperation operation) { switch (operation.Target) { case "Selector": - endpoint.Selectors = [ - ..endpoint.Selectors, - new StaticWebAssetEndpointSelector - { - Name = operation.Name, - Value = operation.Value, - Quality = operation.Quality - }]; + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(endpoint.SelectorsString, _selectorsList); + _selectorsList.Add(new StaticWebAssetEndpointSelector + { + Name = operation.Name, + Value = operation.Value, + Quality = operation.Quality + }); + var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, _serializationContext); + endpoint.SetSelectorsString(selectorsString); break; case "Header": - endpoint.ResponseHeaders = [ - ..endpoint.ResponseHeaders, - new StaticWebAssetEndpointResponseHeader - { - Name = operation.Name, - Value = operation.Value - }]; + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(endpoint.ResponseHeadersString, _headersList); + _headersList.Add(new StaticWebAssetEndpointResponseHeader + { + Name = operation.Name, + Value = operation.Value + }); + var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, _serializationContext); + endpoint.SetResponseHeadersString(headersString); break; case "Property": - endpoint.EndpointProperties = [ - ..endpoint.EndpointProperties, - new StaticWebAssetEndpointProperty - { - Name = operation.Name, - Value = operation.Value - }]; + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpoint.EndpointPropertiesString, _propertiesList); + _propertiesList.Add(new StaticWebAssetEndpointProperty + { + Name = operation.Name, + Value = operation.Value + }); + var propertiesString = StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesList, _serializationContext); + endpoint.SetEndpointPropertiesString(propertiesString); break; default: throw new InvalidOperationException($"Unknown target {operation.Target}"); diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/JsonWriterContext.cs b/src/StaticWebAssetsSdk/Tasks/Utils/JsonWriterContext.cs new file mode 100644 index 000000000000..a60c98c99bcd --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/JsonWriterContext.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Text.Json; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; + +/// +/// A reusable context for high-performance JSON writing using pooled buffers. +/// This struct encapsulates a PooledArrayBufferWriter and Utf8JsonWriter to eliminate +/// allocations during repeated JSON serialization operations. +/// +internal struct JsonWriterContext : IDisposable +{ + internal static readonly JsonWriterOptions WriterOptions = new JsonWriterOptions + { + SkipValidation = true, + Indented = false, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public PooledArrayBufferWriter Buffer { get; private set; } + public Utf8JsonWriter Writer { get; private set; } + + /// + /// Resets the context for reuse, creating the buffer and writer if needed + /// and clearing any existing content. + /// + public void Reset() + { + Buffer ??= new PooledArrayBufferWriter(); + Writer ??= new Utf8JsonWriter(Buffer, WriterOptions); + Buffer.Clear(); + Writer.Reset(Buffer); + } + + /// + /// Deconstructs the context into its buffer and writer components. + /// + public void Deconstruct(out PooledArrayBufferWriter buffer, out Utf8JsonWriter writer) + { + buffer = Buffer; + writer = Writer; + } + + /// + /// Disposes the writer and buffer resources. + /// + public void Dispose() + { + Writer?.Dispose(); + Buffer?.Dispose(); + } +} diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/PooledArrayBufferWriter.cs b/src/StaticWebAssetsSdk/Tasks/Utils/PooledArrayBufferWriter.cs new file mode 100644 index 000000000000..6e1dc1f918fc --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/Utils/PooledArrayBufferWriter.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Copied from https://github.com/dotnet/corefx/blob/b0751dcd4a419ba6731dcaa7d240a8a1946c934c/src/System.Text.Json/src/System/Text/Json/Serialization/ArrayBufferWriter.cs + +using System.Diagnostics; + +namespace System.Buffers; + +internal sealed class PooledArrayBufferWriter : IBufferWriter, IDisposable +{ + private T[] _rentedBuffer; + private int _index; + + private const int MinimumBufferSize = 256; + + public PooledArrayBufferWriter() + { + _rentedBuffer = ArrayPool.Shared.Rent(MinimumBufferSize); + _index = 0; + } + + public PooledArrayBufferWriter(int initialCapacity) + { + _rentedBuffer = ArrayPool.Shared.Rent(initialCapacity); + _index = 0; + } + + public ReadOnlyMemory WrittenMemory + { + get + { + CheckIfDisposed(); + + return _rentedBuffer.AsMemory(0, _index); + } + } + + public int WrittenCount + { + get + { + CheckIfDisposed(); + + return _index; + } + } + + public int Capacity + { + get + { + CheckIfDisposed(); + + return _rentedBuffer.Length; + } + } + + public int FreeCapacity + { + get + { + CheckIfDisposed(); + + return _rentedBuffer.Length - _index; + } + } + + public void Clear() + { + CheckIfDisposed(); + + ClearHelper(); + } + + private void ClearHelper() + { + Debug.Assert(_rentedBuffer != null); + + // _rentedBuffer.AsSpan(0, _index).Clear(); + _index = 0; + } + + // Returns the rented buffer back to the pool + public void Dispose() + { + if (_rentedBuffer == null) + { + return; + } + + ClearHelper(); + ArrayPool.Shared.Return(_rentedBuffer); + _rentedBuffer = null!; + } + + private void CheckIfDisposed() + { + if (_rentedBuffer == null) + { + ThrowObjectDisposedException(); + } + } + + private static void ThrowObjectDisposedException() + { + throw new ObjectDisposedException(nameof(PooledArrayBufferWriter)); + } + + public void Advance(int count) + { + CheckIfDisposed(); + + if (_index > _rentedBuffer.Length - count) + { + ThrowInvalidOperationException(_rentedBuffer.Length); + } + + _index += count; + } + + public (T[] Array, int Count) GetArray() + { + CheckIfDisposed(); + + return (_rentedBuffer, _index); + } + + public Memory GetMemory(int sizeHint = 0) + { + CheckIfDisposed(); + + CheckAndResizeBuffer(sizeHint); + return _rentedBuffer.AsMemory(_index); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckIfDisposed(); + + CheckAndResizeBuffer(sizeHint); + return _rentedBuffer.AsSpan(_index); + } + + private void CheckAndResizeBuffer(int sizeHint) + { + Debug.Assert(_rentedBuffer != null); + + if (sizeHint == 0) + { + sizeHint = MinimumBufferSize; + } + + var availableSpace = _rentedBuffer.Length - _index; + + if (sizeHint > availableSpace) + { + var growBy = Math.Max(sizeHint, _rentedBuffer.Length); + + var newSize = checked(_rentedBuffer.Length + growBy); + + var oldBuffer = _rentedBuffer; + + _rentedBuffer = ArrayPool.Shared.Rent(newSize); + + Debug.Assert(oldBuffer.Length >= _index); + Debug.Assert(_rentedBuffer.Length >= _index); + + var previousBuffer = oldBuffer.AsSpan(0, _index); + previousBuffer.CopyTo(_rentedBuffer); + previousBuffer.Clear(); + ArrayPool.Shared.Return(oldBuffer); + } + + Debug.Assert(_rentedBuffer.Length - _index > 0); + Debug.Assert(_rentedBuffer.Length - _index >= sizeHint); + } + + private static void ThrowInvalidOperationException(int capacity) + { + throw new InvalidOperationException($"Cannot advance past the end of the buffer, which has a size of {capacity}."); + } +} diff --git a/src/StaticWebAssetsSdk/benchmarks/EmptyBenchmark.cs b/src/StaticWebAssetsSdk/benchmarks/EmptyBenchmark.cs index 51cf60eb491d..f787b941ca04 100644 --- a/src/StaticWebAssetsSdk/benchmarks/EmptyBenchmark.cs +++ b/src/StaticWebAssetsSdk/benchmarks/EmptyBenchmark.cs @@ -3,7 +3,7 @@ using BenchmarkDotNet.Attributes; -namespace Microsoft.NET.Sdk.StaticWebAssets.Benchmarks; +namespace Microsoft.AspNetCore.StaticWebAssets.Benchmarks; [MemoryDiagnoser] public class EmptyBenchmark diff --git a/src/StaticWebAssetsSdk/benchmarks/Microsoft.NET.Sdk.StaticWebAssets.Benchmarks.csproj b/src/StaticWebAssetsSdk/benchmarks/Microsoft.NET.Sdk.StaticWebAssets.Benchmarks.csproj index ef7f070f1612..a9e9c8bae5be 100644 --- a/src/StaticWebAssetsSdk/benchmarks/Microsoft.NET.Sdk.StaticWebAssets.Benchmarks.csproj +++ b/src/StaticWebAssetsSdk/benchmarks/Microsoft.NET.Sdk.StaticWebAssets.Benchmarks.csproj @@ -3,7 +3,7 @@ Exe net10.0;net472 Benchmarks for Microsoft.NET.Sdk.StaticWebAssets - Microsoft.NET.Sdk.StaticWebAssets.Benchmarks + Microsoft.AspNetCore.StaticWebAssets.Benchmarks false true diff --git a/src/StaticWebAssetsSdk/benchmarks/Program.cs b/src/StaticWebAssetsSdk/benchmarks/Program.cs index ebfac7af6263..22e3d32b15b9 100644 --- a/src/StaticWebAssetsSdk/benchmarks/Program.cs +++ b/src/StaticWebAssetsSdk/benchmarks/Program.cs @@ -3,7 +3,7 @@ using BenchmarkDotNet.Running; -namespace Microsoft.NET.Sdk.StaticWebAssets.Benchmarks; +namespace Microsoft.AspNetCore.StaticWebAssets.Benchmarks; public class Program { diff --git a/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointPropertyBenchmarks.cs b/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointPropertyBenchmarks.cs new file mode 100644 index 000000000000..9b8a4e26fa1f --- /dev/null +++ b/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointPropertyBenchmarks.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; + +namespace Microsoft.AspNetCore.StaticWebAssets.Benchmarks; + +[MemoryDiagnoser] +public class StaticWebAssetEndpointPropertyBenchmarks +{ + private const string TestValue = """[{"Name":"label","Value":"resource.ext"},{"Name":"integrity","Value":"sha256-abcdef1234567890abcdef1234567890abcdef12"}]"""; + + private readonly List _properties = []; + private readonly StaticWebAssetEndpointProperty[] _propertiesArray; + private readonly List _propertiesList; + private readonly JsonWriterContext _context; + + public StaticWebAssetEndpointPropertyBenchmarks() + { + // Initialize test data for ToMetadataValue benchmarks + _propertiesArray = StaticWebAssetEndpointProperty.FromMetadataValue(TestValue); + _propertiesList = new List(_propertiesArray); + _context = StaticWebAssetEndpointProperty.CreateWriter(); + _context.Reset(); + } + + [Benchmark] + public StaticWebAssetEndpointProperty[] FromMetadataValue_Current() + { + return StaticWebAssetEndpointProperty.FromMetadataValue(TestValue); + } + + [Benchmark] + public List PopulateFromMetadataValue_New() + { + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(TestValue, _properties); + _properties.Clear(); + return _properties; + } + + [Benchmark] + public string ToMetadataValue_Current() + { + return StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesArray); + } + + [Benchmark] + public string ToMetadataValue_New() + { + return StaticWebAssetEndpointProperty.ToMetadataValue(_propertiesList, _context); + } +} diff --git a/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointResponseHeaderBenchmarks.cs b/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointResponseHeaderBenchmarks.cs new file mode 100644 index 000000000000..6247c047cd37 --- /dev/null +++ b/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointResponseHeaderBenchmarks.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; + +namespace Microsoft.AspNetCore.StaticWebAssets.Benchmarks; + +[MemoryDiagnoser] +public class StaticWebAssetEndpointResponseHeaderBenchmarks +{ + private const string TestValue = """[{"Name":"Accept-Ranges","Value":"bytes"},{"Name":"Cache-Control","Value":"no-cache"},{"Name":"Content-Encoding","Value":"gzip"},{"Name":"Content-Length","Value":"__content-length__"},{"Name":"Content-Type","Value":"text/javascript"},{"Name":"ETag","Value":"__etag__"},{"Name":"Last-Modified","Value":"__last-modified__"},{"Name":"Vary","Value":"Content-Encoding"}]"""; + + private readonly List _headers = []; + private readonly StaticWebAssetEndpointResponseHeader[] _headersArray; + private readonly List _headersList; + private readonly JsonWriterContext _context; + + public StaticWebAssetEndpointResponseHeaderBenchmarks() + { + // Initialize test data for ToMetadataValue benchmarks + _headersArray = StaticWebAssetEndpointResponseHeader.FromMetadataValue(TestValue); + _headersList = new List(_headersArray); + _context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + _context.Reset(); + } + + [Benchmark] + public StaticWebAssetEndpointResponseHeader[] FromMetadataValue_Current() + { + return StaticWebAssetEndpointResponseHeader.FromMetadataValue(TestValue); + } + + [Benchmark] + public List PopulateFromMetadataValue_New() + { + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(TestValue, _headers); + _headers.Clear(); + return _headers; + } + + [Benchmark] + public string ToMetadataValue_Current() + { + return StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersArray); + } + + [Benchmark] + public string ToMetadataValue_New() + { + return StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, _context); + } +} diff --git a/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointSelectorBenchmarks.cs b/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointSelectorBenchmarks.cs new file mode 100644 index 000000000000..2f1ac8fcb6c6 --- /dev/null +++ b/src/StaticWebAssetsSdk/benchmarks/StaticWebAssetEndpointSelectorBenchmarks.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; + +namespace Microsoft.AspNetCore.StaticWebAssets.Benchmarks; + +[MemoryDiagnoser] +public class StaticWebAssetEndpointSelectorBenchmarks +{ + private const string TestValue = """[{"Name":"Content-Encoding","Value":"gzip","Quality":"0.100000000000"},{"Name":"Content-Encoding","Value":"br","Quality":"0.5"}]"""; + + private readonly List _selectors = []; + private readonly StaticWebAssetEndpointSelector[] _selectorsArray; + private readonly List _selectorsList; + private readonly JsonWriterContext _context; + + public StaticWebAssetEndpointSelectorBenchmarks() + { + // Initialize test data for ToMetadataValue benchmarks + _selectorsArray = StaticWebAssetEndpointSelector.FromMetadataValue(TestValue); + _selectorsList = new List(_selectorsArray); + _context = StaticWebAssetEndpointSelector.CreateWriter(); + _context.Reset(); + } + + [Benchmark] + public StaticWebAssetEndpointSelector[] FromMetadataValue_Current() + { + return StaticWebAssetEndpointSelector.FromMetadataValue(TestValue); + } + + [Benchmark] + public List PopulateFromMetadataValue_New() + { + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(TestValue, _selectors); + _selectors.Clear(); + return _selectors; + } + + [Benchmark] + public string ToMetadataValue_Current() + { + return StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsArray); + } + + [Benchmark] + public string ToMetadataValue_New() + { + return StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, _context); + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ApplyCompressionNegotiationTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ApplyCompressionNegotiationTest.cs index 86cb1150b5e6..a30163ef1bfc 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ApplyCompressionNegotiationTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ApplyCompressionNegotiationTest.cs @@ -930,6 +930,65 @@ public void AppliesContentNegotiationRules_ToAllRelatedAssetEndpoints() ]); } + [Fact] + public void AppliesContentNegotiationRules_DoesNotFailWithCollectionModifiedException() + { + // This test reproduces the bug where shared lists were being modified during enumeration + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new ApplyCompressionNegotiation + { + BuildEngine = buildEngine.Object, + CandidateAssets = + [ + CreateCandidate( + Path.Combine("wwwroot", "candidate.js"), + "MyPackage", + "Discovered", + "candidate.js", + "All", + "All", + "original-fingerprint", + "original", + fileLength: 20 + ), + CreateCandidate( + Path.Combine("compressed", "candidate.js.gz"), + "MyPackage", + "Discovered", + "candidate.js", + "All", + "All", + "compressed-fingerprint", + "compressed", + Path.Combine("wwwroot", "candidate.js"), + "Content-Encoding", + "gzip", + 9 + ) + ], + CandidateEndpoints = + [ + CreateCandidateEndpoint( + "candidate.js", + Path.Combine("wwwroot", "candidate.js"), + CreateHeaders("text/javascript", [("Content-Length", "20"), ("Cache-Control", "max-age=3600"), ("ETag", "\"original-etag\""), ("Last-Modified", "Wed, 01 Jan 2020 00:00:00 GMT")])), + + CreateCandidateEndpoint( + "candidate.js.gz", + Path.Combine("compressed", "candidate.js.gz"), + CreateHeaders("text/javascript", [("Content-Length", "9"), ("Cache-Control", "max-age=3600"), ("ETag", "\"compressed-etag\""), ("Last-Modified", "Wed, 01 Jan 2020 00:00:00 GMT")])) + ], + }; + + // Act & Assert - This should not throw a CollectionModifiedException + var result = task.Execute(); + result.Should().Be(true); + } + [Fact] public void AppliesContentNegotiationRules_IgnoresAlreadyProcessedEndpoints() { diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs index 38ddc3ca5f5d..fc541d1fd00b 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ComputeEndpointsForReferenceStaticWebAssetsTest.cs @@ -60,7 +60,8 @@ public void UpdatesLabelAsNecessary_ForChosenEndpoints() task.Endpoints.Should().ContainSingle(); task.Endpoints[0].ItemSpec.Should().Be("base/candidate.js"); task.Endpoints[0].GetMetadata("AssetFile").Should().Be(Path.GetFullPath(Path.Combine("wwwroot", "candidate.js"))); - var properties = StaticWebAssetEndpointProperty.FromMetadataValue(task.Endpoints[0].GetMetadata("EndpointProperties")); + var properties = new List(); + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(task.Endpoints[0].GetMetadata("EndpointProperties"), properties); properties.Should().ContainSingle(); properties[0].Name.Should().Be("label"); properties[0].Value.Should().Be("base/label-value"); diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointPropertyTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointPropertyTest.cs new file mode 100644 index 000000000000..196074a75c2f --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointPropertyTest.cs @@ -0,0 +1,331 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class StaticWebAssetEndpointPropertyTest +{ + [Fact] + public void PopulateFromMetadataValue_ValidJson_ParsesCorrectly() + { + // Arrange + var json = """[{"Name":"label","Value":"test-label"},{"Name":"integrity","Value":"sha256-abc123"}]"""; + var properties = new List(); + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert + properties.Should().HaveCount(2); + properties[0].Name.Should().Be("label"); + properties[0].Value.Should().Be("test-label"); + properties[1].Name.Should().Be("integrity"); + properties[1].Value.Should().Be("sha256-abc123"); + } + + [Fact] + public void PopulateFromMetadataValue_WellKnownProperties_UsesInternedStrings() + { + // Arrange + var json = """[{"Name":"label","Value":"test-value"},{"Name":"integrity","Value":"test-integrity"}]"""; + var properties = new List(); + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert + properties.Should().HaveCount(2); + + // Should use interned strings for well-known property names + properties[0].Name.Should().BeSameAs(WellKnownEndpointPropertyNames.Label); + properties[1].Name.Should().BeSameAs(WellKnownEndpointPropertyNames.Integrity); + } + + [Fact] + public void PopulateFromMetadataValue_UnknownProperties_UsesOriginalStrings() + { + // Arrange + var json = """[{"Name":"custom-property","Value":"custom-value"}]"""; + var properties = new List(); + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert + properties.Should().HaveCount(1); + properties[0].Name.Should().Be("custom-property"); + properties[0].Value.Should().Be("custom-value"); + + // Should not be the same instance as interned strings + properties[0].Name.Should().NotBeSameAs(WellKnownEndpointPropertyNames.Label); + properties[0].Name.Should().NotBeSameAs(WellKnownEndpointPropertyNames.Integrity); + } + + [Fact] + public void PopulateFromMetadataValue_EmptyJson_DoesNotAddProperties() + { + // Arrange + var json = "[]"; + var properties = new List(); + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert + properties.Should().BeEmpty(); + } + + [Fact] + public void PopulateFromMetadataValue_NullOrEmptyString_DoesNotAddProperties() + { + // Arrange + var properties = new List(); + + // Act & Assert - should not throw + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(null, properties); + properties.Should().BeEmpty(); + + StaticWebAssetEndpointProperty.PopulateFromMetadataValue("", properties); + properties.Should().BeEmpty(); + } + + [Fact] + public void PopulateFromMetadataValue_InvalidJson_ThrowsJsonException() + { + // Arrange + var json = """[{"Name":"label","Value":}]"""; // Invalid JSON + var properties = new List(); + + // Act & Assert + var action = () => StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + action.Should().Throw(); + } + + [Fact] + public void PopulateFromMetadataValue_MixedProperties_HandlesCorrectly() + { + // Arrange + var json = """[{"Name":"label","Value":"known-value"},{"Name":"custom-prop","Value":"custom-value"},{"Name":"integrity","Value":"known-integrity"}]"""; + var properties = new List(); + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert + properties.Should().HaveCount(3); + + // Well-known properties should use interned strings + properties[0].Name.Should().BeSameAs(WellKnownEndpointPropertyNames.Label); + properties[2].Name.Should().BeSameAs(WellKnownEndpointPropertyNames.Integrity); + + // Custom property should not use interned strings + properties[1].Name.Should().Be("custom-prop"); + properties[1].Name.Should().NotBeSameAs(WellKnownEndpointPropertyNames.Label); + properties[1].Name.Should().NotBeSameAs(WellKnownEndpointPropertyNames.Integrity); + } + + [Fact] + public void PopulateFromMetadataValue_ExistingList_ClearsExistingItems_BeforeAppendingElements() + { + // Arrange + var json = """[{"Name":"label","Value":"new-value"}]"""; + var properties = new List + { + new() { Name = "existing", Value = "existing-value" } + }; + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert + properties.Should().HaveCount(1); + properties[0].Name.Should().Be("label"); + properties[0].Value.Should().Be("new-value"); + } + + [Fact] + public void PopulateFromMetadataValue_ExistingList_ClearsExistingItems_ValidatesClearingBehavior() + { + // Arrange + var json = """[{"Name":"newProp","Value":"newValue"}]"""; + var properties = new List + { + new() { Name = "existing1", Value = "existingValue1" }, + new() { Name = "existing2", Value = "existingValue2" } + }; + + // Act + StaticWebAssetEndpointProperty.PopulateFromMetadataValue(json, properties); + + // Assert - List should be cleared and contain only the new property + properties.Should().HaveCount(1); + properties[0].Name.Should().Be("newProp"); + properties[0].Value.Should().Be("newValue"); + } + + [Fact] + public void ToMetadataValue_List_EmptyList_ReturnsEmptyJsonArray() + { + // Arrange + var properties = new List(); + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointProperty.ToMetadataValue(properties, context); + + // Assert + result.Should().Be("[]"); + } + + [Fact] + public void ToMetadataValue_List_NullList_ReturnsEmptyJsonArray() + { + // Arrange + List? properties = null; + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointProperty.ToMetadataValue(properties, context); + + // Assert + result.Should().Be("[]"); + } + + [Fact] + public void ToMetadataValue_List_SingleProperty_ReturnsValidJson() + { + // Arrange + var properties = new List + { + new() { Name = "label", Value = "test-label" } + }; + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointProperty.ToMetadataValue(properties, context); + + // Assert + result.Should().Be("""[{"Name":"label","Value":"test-label"}]"""); + } + + [Fact] + public void ToMetadataValue_List_MultipleProperties_ReturnsValidJson() + { + // Arrange + var properties = new List + { + new() { Name = "label", Value = "test-label" }, + new() { Name = "integrity", Value = "sha256-abc123" } + }; + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointProperty.ToMetadataValue(properties, context); + + // Assert + result.Should().Be("""[{"Name":"label","Value":"test-label"},{"Name":"integrity","Value":"sha256-abc123"}]"""); + } + + [Fact] + public void ToMetadataValue_List_SpecialCharacters_EscapesCorrectly() + { + // Arrange + var properties = new List + { + new() { Name = "quote", Value = "\"quoted\"" }, + new() { Name = "backslash", Value = "back\\slash" }, + new() { Name = "newline", Value = "line\nbreak" } + }; + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointProperty.ToMetadataValue(properties, context); + + // Assert + // The result should be valid JSON that can be parsed back + var parsed = JsonSerializer.Deserialize(result); + parsed.Should().HaveCount(3); + parsed[0].Name.Should().Be("quote"); + parsed[0].Value.Should().Be("\"quoted\""); + parsed[1].Name.Should().Be("backslash"); + parsed[1].Value.Should().Be("back\\slash"); + parsed[2].Name.Should().Be("newline"); + parsed[2].Value.Should().Be("line\nbreak"); + } + + [Fact] + public void ToMetadataValue_List_ReuseBufferAndWriter_WorksCorrectly() + { + // Arrange + var properties1 = new List + { + new() { Name = "label1", Value = "value1" } + }; + var properties2 = new List + { + new() { Name = "label2", Value = "value2" } + }; + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act - First serialization + var result1 = StaticWebAssetEndpointProperty.ToMetadataValue(properties1, context); + + // Act - Second serialization with same context + var result2 = StaticWebAssetEndpointProperty.ToMetadataValue(properties2, context); + + // Assert + result1.Should().Be("""[{"Name":"label1","Value":"value1"}]"""); + result2.Should().Be("""[{"Name":"label2","Value":"value2"}]"""); + } + + [Fact] + public void ToMetadataValue_ArrayAndList_SameInput_ProduceSameOutput() + { + // Arrange + var arrayProperties = new[] + { + new StaticWebAssetEndpointProperty { Name = "label", Value = "test-label" }, + new StaticWebAssetEndpointProperty { Name = "integrity", Value = "sha256-abc123" } + }; + var listProperties = new List(arrayProperties); + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var arrayResult = StaticWebAssetEndpointProperty.ToMetadataValue(arrayProperties); + var listResult = StaticWebAssetEndpointProperty.ToMetadataValue(listProperties, context); + + // Assert - Both should produce semantically equivalent JSON + var arrayParsed = JsonSerializer.Deserialize(arrayResult); + var listParsed = JsonSerializer.Deserialize(listResult); + + arrayParsed.Should().BeEquivalentTo(listParsed); + } + + [Fact] + public void ToMetadataValue_List_LargeInput_HandlesCorrectly() + { + // Arrange - Create a large list to test buffer resizing + var properties = new List(); + for (int i = 0; i < 100; i++) + { + properties.Add(new StaticWebAssetEndpointProperty { Name = $"prop{i}", Value = $"value{i}" }); + } + using var context = StaticWebAssetEndpointProperty.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointProperty.ToMetadataValue(properties, context); + + // Assert - Should be valid JSON and contain all properties + var parsed = JsonSerializer.Deserialize(result); + parsed.Should().HaveCount(100); + + for (int i = 0; i < 100; i++) + { + parsed[i].Name.Should().Be($"prop{i}"); + parsed[i].Value.Should().Be($"value{i}"); + } + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointResponseHeaderTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointResponseHeaderTest.cs new file mode 100644 index 000000000000..e0cbcc13d4b7 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointResponseHeaderTest.cs @@ -0,0 +1,355 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class StaticWebAssetEndpointResponseHeaderTest +{ + [Fact] + public void PopulateFromMetadataValue_ValidJson_ParsesCorrectly() + { + // Arrange + var json = """[{"Name":"Content-Type","Value":"text/javascript"},{"Name":"Cache-Control","Value":"public, max-age=31536000"}]"""; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(2); + headers[0].Name.Should().Be("Content-Type"); + headers[0].Value.Should().Be("text/javascript"); + headers[1].Name.Should().Be("Cache-Control"); + headers[1].Value.Should().Be("public, max-age=31536000"); + } + + [Fact] + public void PopulateFromMetadataValue_WellKnownHeaders_UsesInternedStrings() + { + // Arrange + var json = """[{"Name":"Content-Type","Value":"application/json"},{"Name":"Cache-Control","Value":"no-cache"}]"""; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(2); + + // Should use interned strings for well-known header names + headers[0].Name.Should().BeSameAs(WellKnownResponseHeaders.ContentType); + headers[1].Name.Should().BeSameAs(WellKnownResponseHeaders.CacheControl); + } + + [Fact] + public void PopulateFromMetadataValue_WellKnownHeaderValues_UsesInternedStrings() + { + // Arrange + var json = """[{"Name":"Content-Type","Value":"text/javascript"},{"Name":"Content-Encoding","Value":"gzip"}]"""; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(2); + + // Should use interned strings for well-known header values + headers[0].Value.Should().BeSameAs(WellKnownResponseHeaderValues.TextJavascript); + headers[1].Value.Should().BeSameAs(WellKnownResponseHeaderValues.Gzip); + } + + [Fact] + public void PopulateFromMetadataValue_UnknownHeaders_UsesOriginalStrings() + { + // Arrange + var json = """[{"Name":"X-Custom-Header","Value":"custom-value"}]"""; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(1); + headers[0].Name.Should().Be("X-Custom-Header"); + headers[0].Value.Should().Be("custom-value"); + + // Should not be the same instance as interned strings + headers[0].Name.Should().NotBeSameAs(WellKnownResponseHeaders.ContentType); + headers[0].Value.Should().NotBeSameAs(WellKnownResponseHeaderValues.TextJavascript); + } + + [Fact] + public void PopulateFromMetadataValue_EmptyJson_DoesNotAddHeaders() + { + // Arrange + var json = "[]"; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().BeEmpty(); + } + + [Fact] + public void PopulateFromMetadataValue_NullOrEmptyString_DoesNotAddHeaders() + { + // Arrange + var headers = new List(); + + // Act & Assert - should not throw + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(null, headers); + headers.Should().BeEmpty(); + + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue("", headers); + headers.Should().BeEmpty(); + } + + [Fact] + public void PopulateFromMetadataValue_InvalidJson_ThrowsJsonException() + { + // Arrange + var json = """[{"Name":"Content-Type","Value":}]"""; // Invalid JSON + var headers = new List(); + + // Act & Assert + var action = () => StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + action.Should().Throw(); + } + + [Fact] + public void PopulateFromMetadataValue_MixedHeaders_HandlesCorrectly() + { + // Arrange + var json = """[{"Name":"Content-Type","Value":"text/javascript"},{"Name":"X-Custom","Value":"custom"},{"Name":"Cache-Control","Value":"no-cache"}]"""; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(3); + + // Well-known headers should use interned strings + headers[0].Name.Should().BeSameAs(WellKnownResponseHeaders.ContentType); + headers[0].Value.Should().BeSameAs(WellKnownResponseHeaderValues.TextJavascript); + headers[2].Name.Should().BeSameAs(WellKnownResponseHeaders.CacheControl); + headers[2].Value.Should().BeSameAs(WellKnownResponseHeaderValues.NoCache); + + // Custom header should not use interned strings + headers[1].Name.Should().Be("X-Custom"); + headers[1].Value.Should().Be("custom"); + headers[1].Name.Should().NotBeSameAs(WellKnownResponseHeaders.ContentType); + headers[1].Value.Should().NotBeSameAs(WellKnownResponseHeaderValues.TextJavascript); + } + + [Fact] + public void PopulateFromMetadataValue_ExistingList_ClearsExistingItems_BeforeAppendingElements() + { + // Arrange + var json = """[{"Name":"Content-Type","Value":"application/json"}]"""; + var headers = new List + { + new() { Name = "X-Existing", Value = "existing-value" } + }; + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(1); + headers[0].Name.Should().Be("Content-Type"); + headers[0].Value.Should().Be("application/json"); + } + + [Fact] + public void PopulateFromMetadataValue_CaseSensitiveHeaderNames_UsesInternedStrings() + { + // Arrange - header names are case-sensitive in our implementation + var json = """[{"Name":"Content-Type","Value":"text/javascript"},{"Name":"content-type","Value":"no-cache"}]"""; + var headers = new List(); + + // Act + StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(json, headers); + + // Assert + headers.Should().HaveCount(2); + + // Exact match should use interned string + headers[0].Name.Should().BeSameAs(WellKnownResponseHeaders.ContentType); + headers[0].Value.Should().BeSameAs(WellKnownResponseHeaderValues.TextJavascript); + + // Different case should not use interned string + headers[1].Name.Should().Be("content-type"); + headers[1].Name.Should().NotBeSameAs(WellKnownResponseHeaders.ContentType); + } + + [Fact] + public void ToMetadataValue_List_EmptyList_ReturnsEmptyJsonArray() + { + // Arrange + var headers = new List(); + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers, context); + + // Assert + result.Should().Be("[]"); + } + + [Fact] + public void ToMetadataValue_List_NullList_ReturnsEmptyJsonArray() + { + // Arrange + List? headers = null; + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers, context); + + // Assert + result.Should().Be("[]"); + } + + [Fact] + public void ToMetadataValue_List_SingleHeader_ReturnsValidJson() + { + // Arrange + var headers = new List + { + new() { Name = "Content-Type", Value = "application/json" } + }; + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers, context); + + // Assert + result.Should().Be("""[{"Name":"Content-Type","Value":"application/json"}]"""); + } + + [Fact] + public void ToMetadataValue_List_MultipleHeaders_ReturnsValidJson() + { + // Arrange + var headers = new List + { + new() { Name = "Content-Type", Value = "text/javascript" }, + new() { Name = "Cache-Control", Value = "no-cache" } + }; + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers, context); + + // Assert + result.Should().Be("""[{"Name":"Content-Type","Value":"text/javascript"},{"Name":"Cache-Control","Value":"no-cache"}]"""); + } + + [Fact] + public void ToMetadataValue_List_SpecialCharacters_EscapesCorrectly() + { + // Arrange + var headers = new List + { + new() { Name = "X-Quote", Value = "\"quoted\"" }, + new() { Name = "X-Backslash", Value = "back\\slash" }, + new() { Name = "X-Newline", Value = "line\nbreak" } + }; + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers, context); + + // Assert + // The result should be valid JSON that can be parsed back + var parsed = JsonSerializer.Deserialize(result); + parsed.Should().HaveCount(3); + parsed[0].Name.Should().Be("X-Quote"); + parsed[0].Value.Should().Be("\"quoted\""); + parsed[1].Name.Should().Be("X-Backslash"); + parsed[1].Value.Should().Be("back\\slash"); + parsed[2].Name.Should().Be("X-Newline"); + parsed[2].Value.Should().Be("line\nbreak"); + } + + [Fact] + public void ToMetadataValue_List_ReuseBufferAndWriter_WorksCorrectly() + { + // Arrange + var headers1 = new List + { + new() { Name = "Content-Type", Value = "application/json" } + }; + var headers2 = new List + { + new() { Name = "Cache-Control", Value = "no-cache" } + }; + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act - First serialization + var result1 = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers1, context); + + // Act - Second serialization with same context + var result2 = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers2, context); + + // Assert + result1.Should().Be("""[{"Name":"Content-Type","Value":"application/json"}]"""); + result2.Should().Be("""[{"Name":"Cache-Control","Value":"no-cache"}]"""); + } + + [Fact] + public void ToMetadataValue_ArrayAndList_SameInput_ProduceSameOutput() + { + // Arrange + var arrayHeaders = new[] + { + new StaticWebAssetEndpointResponseHeader { Name = "Content-Type", Value = "text/javascript" }, + new StaticWebAssetEndpointResponseHeader { Name = "Cache-Control", Value = "no-cache" } + }; + var listHeaders = new List(arrayHeaders); + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var arrayResult = StaticWebAssetEndpointResponseHeader.ToMetadataValue(arrayHeaders); + var listResult = StaticWebAssetEndpointResponseHeader.ToMetadataValue(listHeaders, context); + + // Assert - Both should produce semantically equivalent JSON + var arrayParsed = JsonSerializer.Deserialize(arrayResult); + var listParsed = JsonSerializer.Deserialize(listResult); + + arrayParsed.Should().BeEquivalentTo(listParsed); + } + + [Fact] + public void ToMetadataValue_List_LargeInput_HandlesCorrectly() + { + // Arrange - Create a large list to test buffer resizing + var headers = new List(); + for (int i = 0; i < 100; i++) + { + headers.Add(new StaticWebAssetEndpointResponseHeader { Name = $"X-Header{i}", Value = $"value{i}" }); + } + using var context = StaticWebAssetEndpointResponseHeader.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointResponseHeader.ToMetadataValue(headers, context); + + // Assert - Should be valid JSON and contain all headers + var parsed = JsonSerializer.Deserialize(result); + parsed.Should().HaveCount(100); + + for (int i = 0; i < 100; i++) + { + parsed[i].Name.Should().Be($"X-Header{i}"); + parsed[i].Value.Should().Be($"value{i}"); + } + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointSelectorTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointSelectorTest.cs new file mode 100644 index 000000000000..51dcedb8b40a --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointSelectorTest.cs @@ -0,0 +1,398 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class StaticWebAssetEndpointSelectorTest +{ + [Fact] + public void PopulateFromMetadataValue_ValidJson_ParsesCorrectly() + { + // Arrange + var json = """[{"Name":"Content-Encoding","Value":"gzip"},{"Name":"Accept","Value":"application/json"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(2); + selectors[0].Name.Should().Be("Content-Encoding"); + selectors[0].Value.Should().Be("gzip"); + selectors[1].Name.Should().Be("Accept"); + selectors[1].Value.Should().Be("application/json"); + } + + [Fact] + public void PopulateFromMetadataValue_WellKnownSelectorNames_UsesInternedStrings() + { + // Arrange + var json = """[{"Name":"Content-Encoding","Value":"gzip"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(1); + + // Should use interned strings for well-known selector names + selectors[0].Name.Should().BeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + } + + [Fact] + public void PopulateFromMetadataValue_WellKnownSelectorValues_UsesInternedStrings() + { + // Arrange + var json = """[{"Name":"Content-Encoding","Value":"gzip"},{"Name":"Content-Encoding","Value":"br"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(2); + + // Should use interned strings for well-known selector values + selectors[0].Value.Should().BeSameAs(WellKnownEndpointSelectorValues.Gzip); + selectors[1].Value.Should().BeSameAs(WellKnownEndpointSelectorValues.Brotli); + } + + [Fact] + public void PopulateFromMetadataValue_UnknownSelectors_UsesOriginalStrings() + { + // Arrange + var json = """[{"Name":"Custom-Selector","Value":"custom-value"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(1); + selectors[0].Name.Should().Be("Custom-Selector"); + selectors[0].Value.Should().Be("custom-value"); + + // Should not be the same instance as interned strings + selectors[0].Name.Should().NotBeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + selectors[0].Value.Should().NotBeSameAs(WellKnownEndpointSelectorValues.Gzip); + } + + [Fact] + public void PopulateFromMetadataValue_EmptyJson_DoesNotAddSelectors() + { + // Arrange + var json = "[]"; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().BeEmpty(); + } + + [Fact] + public void PopulateFromMetadataValue_NullOrEmptyString_DoesNotAddSelectors() + { + // Arrange + var selectors = new List(); + + // Act & Assert - should not throw + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(null, selectors); + selectors.Should().BeEmpty(); + + StaticWebAssetEndpointSelector.PopulateFromMetadataValue("", selectors); + selectors.Should().BeEmpty(); + } + + [Fact] + public void PopulateFromMetadataValue_InvalidJson_ThrowsJsonException() + { + // Arrange + var json = """[{"Name":"Content-Encoding","Value":}]"""; // Invalid JSON + var selectors = new List(); + + // Act & Assert + var action = () => StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + action.Should().Throw(); + } + + [Fact] + public void PopulateFromMetadataValue_MixedSelectors_HandlesCorrectly() + { + // Arrange + var json = """[{"Name":"Content-Encoding","Value":"gzip"},{"Name":"Custom-Selector","Value":"custom"},{"Name":"Content-Encoding","Value":"br"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(3); + + // Well-known selectors should use interned strings + selectors[0].Name.Should().BeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + selectors[0].Value.Should().BeSameAs(WellKnownEndpointSelectorValues.Gzip); + selectors[2].Name.Should().BeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + selectors[2].Value.Should().BeSameAs(WellKnownEndpointSelectorValues.Brotli); + + // Custom selector should not use interned strings + selectors[1].Name.Should().Be("Custom-Selector"); + selectors[1].Value.Should().Be("custom"); + selectors[1].Name.Should().NotBeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + selectors[1].Value.Should().NotBeSameAs(WellKnownEndpointSelectorValues.Gzip); + } + + [Fact] + public void PopulateFromMetadataValue_ExistingList_ClearsExistingItems_BeforeAppendingElements() + { + // Arrange + var json = """[{"Name":"Content-Encoding","Value":"gzip"}]"""; + var selectors = new List + { + new() { Name = "Existing-Selector", Value = "existing-value" } + }; + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(1); + selectors[0].Name.Should().Be("Content-Encoding"); + selectors[0].Value.Should().Be("gzip"); + } + + [Fact] + public void PopulateFromMetadataValue_ExistingList_ClearsExistingItems_ValidatesClearingBehavior() + { + // Arrange + var json = """[{"Name":"Accept-Encoding","Value":"br"}]"""; + var selectors = new List + { + new() { Name = "Content-Encoding", Value = "gzip" }, + new() { Name = "Content-Type", Value = "text/css" } + }; + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert - List should be cleared and contain only the new selector + selectors.Should().HaveCount(1); + selectors[0].Name.Should().Be("Accept-Encoding"); + selectors[0].Value.Should().Be("br"); + } + + [Fact] + public void PopulateFromMetadataValue_CaseSensitiveSelectorNames_UsesInternedStrings() + { + // Arrange - Content-Encoding should be case-sensitive for selector names + var json = """[{"Name":"Content-Encoding","Value":"gzip"},{"Name":"content-encoding","Value":"br"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(2); + + // Exact match should use interned string + selectors[0].Name.Should().BeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + + // Different case should not use interned string + selectors[1].Name.Should().Be("content-encoding"); + selectors[1].Name.Should().NotBeSameAs(WellKnownEndpointSelectorNames.ContentEncoding); + } + + [Fact] + public void PopulateFromMetadataValue_CaseSensitiveSelectorValues_UsesInternedStrings() + { + // Arrange - Compression values should be case-sensitive + var json = """[{"Name":"Content-Encoding","Value":"gzip"},{"Name":"Content-Encoding","Value":"GZIP"}]"""; + var selectors = new List(); + + // Act + StaticWebAssetEndpointSelector.PopulateFromMetadataValue(json, selectors); + + // Assert + selectors.Should().HaveCount(2); + + // Exact match should use interned string + selectors[0].Value.Should().BeSameAs(WellKnownEndpointSelectorValues.Gzip); + + // Different case should not use interned string + selectors[1].Value.Should().Be("GZIP"); + selectors[1].Value.Should().NotBeSameAs(WellKnownEndpointSelectorValues.Gzip); + } + + [Fact] + public void ToMetadataValue_List_EmptyList_ReturnsEmptyJsonArray() + { + // Arrange + var selectors = new List(); + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointSelector.ToMetadataValue(selectors, context); + + // Assert + result.Should().Be("[]"); + } + + [Fact] + public void ToMetadataValue_List_NullList_ReturnsEmptyJsonArray() + { + // Arrange + List? selectors = null; + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointSelector.ToMetadataValue(selectors, context); + + // Assert + result.Should().Be("[]"); + } + + [Fact] + public void ToMetadataValue_List_SingleSelector_ReturnsValidJson() + { + // Arrange + var selectors = new List + { + new() { Name = "Content-Encoding", Value = "gzip", Quality = "1.0" } + }; + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointSelector.ToMetadataValue(selectors, context); + + // Assert + result.Should().Be("""[{"Name":"Content-Encoding","Value":"gzip","Quality":"1.0"}]"""); + } + + [Fact] + public void ToMetadataValue_List_MultipleSelectors_ReturnsValidJson() + { + // Arrange + var selectors = new List + { + new() { Name = "Content-Encoding", Value = "gzip", Quality = "1.0" }, + new() { Name = "Content-Encoding", Value = "br", Quality = "0.8" } + }; + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointSelector.ToMetadataValue(selectors, context); + + // Assert + result.Should().Be("""[{"Name":"Content-Encoding","Value":"gzip","Quality":"1.0"},{"Name":"Content-Encoding","Value":"br","Quality":"0.8"}]"""); + } + + [Fact] + public void ToMetadataValue_List_SpecialCharacters_EscapesCorrectly() + { + // Arrange + var selectors = new List + { + new() { Name = "X-Quote", Value = "\"quoted\"", Quality = "1.0" }, + new() { Name = "X-Backslash", Value = "back\\slash", Quality = "0.9" }, + new() { Name = "X-Newline", Value = "line\nbreak", Quality = "0.8" } + }; + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointSelector.ToMetadataValue(selectors, context); + + // Assert + // The result should be valid JSON that can be parsed back + var parsed = JsonSerializer.Deserialize(result); + parsed.Should().HaveCount(3); + parsed[0].Name.Should().Be("X-Quote"); + parsed[0].Value.Should().Be("\"quoted\""); + parsed[0].Quality.Should().Be("1.0"); + parsed[1].Name.Should().Be("X-Backslash"); + parsed[1].Value.Should().Be("back\\slash"); + parsed[1].Quality.Should().Be("0.9"); + parsed[2].Name.Should().Be("X-Newline"); + parsed[2].Value.Should().Be("line\nbreak"); + parsed[2].Quality.Should().Be("0.8"); + } + + [Fact] + public void ToMetadataValue_List_ReuseBufferAndWriter_WorksCorrectly() + { + // Arrange + var selectors1 = new List + { + new() { Name = "Content-Encoding", Value = "gzip", Quality = "1.0" } + }; + var selectors2 = new List + { + new() { Name = "Content-Encoding", Value = "br", Quality = "0.8" } + }; + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act - First serialization + var result1 = StaticWebAssetEndpointSelector.ToMetadataValue(selectors1, context); + + // Act - Second serialization with same context + var result2 = StaticWebAssetEndpointSelector.ToMetadataValue(selectors2, context); + + // Assert + result1.Should().Be("""[{"Name":"Content-Encoding","Value":"gzip","Quality":"1.0"}]"""); + result2.Should().Be("""[{"Name":"Content-Encoding","Value":"br","Quality":"0.8"}]"""); + } + + [Fact] + public void ToMetadataValue_ArrayAndList_SameInput_ProduceSameOutput() + { + // Arrange + var arraySelectors = new[] + { + new StaticWebAssetEndpointSelector { Name = "Content-Encoding", Value = "gzip", Quality = "1.0" }, + new StaticWebAssetEndpointSelector { Name = "Content-Encoding", Value = "br", Quality = "0.8" } + }; + var listSelectors = new List(arraySelectors); + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var arrayResult = StaticWebAssetEndpointSelector.ToMetadataValue(arraySelectors); + var listResult = StaticWebAssetEndpointSelector.ToMetadataValue(listSelectors, context); + + // Assert - Both should produce semantically equivalent JSON + var arrayParsed = JsonSerializer.Deserialize(arrayResult); + var listParsed = JsonSerializer.Deserialize(listResult); + + arrayParsed.Should().BeEquivalentTo(listParsed); + } + + [Fact] + public void ToMetadataValue_List_LargeInput_HandlesCorrectly() + { + // Arrange - Create a large list to test buffer resizing + var selectors = new List(); + for (int i = 0; i < 100; i++) + { + selectors.Add(new StaticWebAssetEndpointSelector { Name = $"Selector{i}", Value = $"value{i}", Quality = $"0.{i:D2}" }); + } + using var context = StaticWebAssetEndpointSelector.CreateWriter(); + + // Act + var result = StaticWebAssetEndpointSelector.ToMetadataValue(selectors, context); + + // Assert - Should be valid JSON and contain all selectors + var parsed = JsonSerializer.Deserialize(result); + parsed.Should().HaveCount(100); + + for (int i = 0; i < 100; i++) + { + parsed[i].Name.Should().Be($"Selector{i}"); + parsed[i].Value.Should().Be($"value{i}"); + parsed[i].Quality.Should().Be($"0.{i:D2}"); + } + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointTests.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointTests.cs new file mode 100644 index 000000000000..e71117287736 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/StaticWebAssetEndpointTests.cs @@ -0,0 +1,354 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests; + +public class StaticWebAssetEndpointTests +{ + [Fact] + public void Constructor_FromTaskItem_InitializesCorrectly() + { + // Arrange + var taskItem = new TaskItem("test-route"); + taskItem.SetMetadata("AssetFile", "/path/to/asset.js"); + taskItem.SetMetadata("Selectors", "[{\"Name\":\"Content-Type\",\"Value\":\"application/javascript\",\"Quality\":\"\"}]"); + taskItem.SetMetadata("ResponseHeaders", "[{\"Name\":\"Cache-Control\",\"Value\":\"max-age=31536000\"}]"); + taskItem.SetMetadata("EndpointProperties", "[{\"Name\":\"integrity\",\"Value\":\"sha256-test\"}]"); + + // Act + var endpoint = StaticWebAssetEndpoint.FromTaskItem(taskItem); + + // Assert + Assert.Equal("test-route", endpoint.Route); + Assert.Equal("/path/to/asset.js", endpoint.AssetFile); + Assert.Single(endpoint.Selectors); + Assert.Equal("Content-Type", endpoint.Selectors[0].Name); + Assert.Single(endpoint.ResponseHeaders); + Assert.Equal("Cache-Control", endpoint.ResponseHeaders[0].Name); + Assert.Single(endpoint.EndpointProperties); + Assert.Equal("integrity", endpoint.EndpointProperties[0].Name); + } + + [Fact] + public void SetSelectorsString_UpdatesSelectorsProperty() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var selectorsJson = "[{\"Name\":\"Content-Encoding\",\"Value\":\"gzip\",\"Quality\":\"0.8\"}]"; + + // Act + endpoint.SetSelectorsString(selectorsJson); + + // Assert + Assert.Single(endpoint.Selectors); + Assert.Equal("Content-Encoding", endpoint.Selectors[0].Name); + Assert.Equal("gzip", endpoint.Selectors[0].Value); + Assert.Equal("0.8", endpoint.Selectors[0].Quality); + } + + [Fact] + public void SetResponseHeadersString_UpdatesResponseHeadersProperty() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var headersJson = "[{\"Name\":\"Content-Type\",\"Value\":\"text/javascript\"},{\"Name\":\"ETag\",\"Value\":\"\\\"test-etag\\\"\"}]"; + + // Act + endpoint.SetResponseHeadersString(headersJson); + + // Assert + Assert.Equal(2, endpoint.ResponseHeaders.Length); + Assert.Contains(endpoint.ResponseHeaders, h => h.Name == "Content-Type" && h.Value == "text/javascript"); + Assert.Contains(endpoint.ResponseHeaders, h => h.Name == "ETag" && h.Value == "\"test-etag\""); + } + + [Fact] + public void SetEndpointPropertiesString_UpdatesEndpointPropertiesProperty() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var propertiesJson = "[{\"Name\":\"fingerprint\",\"Value\":\"abc123\"},{\"Name\":\"integrity\",\"Value\":\"sha256-test\"}]"; + + // Act + endpoint.SetEndpointPropertiesString(propertiesJson); + + // Assert + Assert.Equal(2, endpoint.EndpointProperties.Length); + Assert.Contains(endpoint.EndpointProperties, p => p.Name == "fingerprint" && p.Value == "abc123"); + Assert.Contains(endpoint.EndpointProperties, p => p.Name == "integrity" && p.Value == "sha256-test"); + } + + [Fact] + public void Selectors_Setter_SortsArray() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var selectors = new[] + { + new StaticWebAssetEndpointSelector { Name = "Content-Type", Value = "text/javascript", Quality = "" }, + new StaticWebAssetEndpointSelector { Name = "Content-Encoding", Value = "gzip", Quality = "0.8" } + }; + + // Act + endpoint.Selectors = selectors; + + // Assert - should be sorted by name + Assert.Equal("Content-Encoding", endpoint.Selectors[0].Name); + Assert.Equal("Content-Type", endpoint.Selectors[1].Name); + } + + [Fact] + public void ResponseHeaders_Setter_SortsArray() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var headers = new[] + { + new StaticWebAssetEndpointResponseHeader { Name = "ETag", Value = "\"test\"" }, + new StaticWebAssetEndpointResponseHeader { Name = "Cache-Control", Value = "max-age=31536000" } + }; + + // Act + endpoint.ResponseHeaders = headers; + + // Assert - should be sorted by name + Assert.Equal("Cache-Control", endpoint.ResponseHeaders[0].Name); + Assert.Equal("ETag", endpoint.ResponseHeaders[1].Name); + } + + [Fact] + public void EndpointProperties_Setter_SortsArray() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var properties = new[] + { + new StaticWebAssetEndpointProperty { Name = "integrity", Value = "sha256-test" }, + new StaticWebAssetEndpointProperty { Name = "fingerprint", Value = "abc123" } + }; + + // Act + endpoint.EndpointProperties = properties; + + // Assert - should be sorted by name + Assert.Equal("fingerprint", endpoint.EndpointProperties[0].Name); + Assert.Equal("integrity", endpoint.EndpointProperties[1].Name); + } + + [Fact] + public void Equals_SameData_ReturnsTrue() + { + // Arrange + var endpoint1 = CreateTestEndpoint(); + var endpoint2 = CreateTestEndpoint(); + + // Act & Assert + Assert.True(endpoint1.Equals(endpoint2)); + Assert.Equal(endpoint1.GetHashCode(), endpoint2.GetHashCode()); + } + + [Fact] + public void Equals_DifferentRoute_ReturnsFalse() + { + // Arrange + var endpoint1 = CreateTestEndpoint(); + var endpoint2 = CreateTestEndpoint(); + endpoint2.Route = "different-route"; + + // Act & Assert + Assert.False(endpoint1.Equals(endpoint2)); + } + + [Fact] + public void Equals_DifferentSelectors_ReturnsFalse() + { + // Arrange + var endpoint1 = CreateTestEndpoint(); + var endpoint2 = CreateTestEndpoint(); + endpoint2.Selectors = new[] { new StaticWebAssetEndpointSelector { Name = "Different", Value = "selector", Quality = "" } }; + + // Act & Assert + Assert.False(endpoint1.Equals(endpoint2)); + } + + [Fact] + public void CompareTo_DifferentRoutes_ReturnsCorrectOrder() + { + // Arrange + var endpoint1 = CreateTestEndpoint(); + endpoint1.Route = "a-route"; + var endpoint2 = CreateTestEndpoint(); + endpoint2.Route = "z-route"; + + // Act + var comparison = endpoint1.CompareTo(endpoint2); + + // Assert + Assert.True(comparison < 0); + } + + [Fact] + public void ToTaskItem_ReturnsValidTaskItem() + { + // Arrange + var endpoint = CreateTestEndpoint(); + + // Act + var taskItem = endpoint.ToTaskItem(); + + // Assert + Assert.Equal(endpoint.Route, taskItem.ItemSpec); + Assert.Equal(endpoint.AssetFile, taskItem.GetMetadata("AssetFile")); + Assert.NotEmpty(taskItem.GetMetadata("Selectors")); + Assert.NotEmpty(taskItem.GetMetadata("ResponseHeaders")); + Assert.NotEmpty(taskItem.GetMetadata("EndpointProperties")); + } + + [Fact] + public void ITaskItem2_GetMetadataValueEscaped_ReturnsCorrectValues() + { + // Arrange + var endpoint = CreateTestEndpoint(); + var taskItem = (ITaskItem2)endpoint; + + // Act & Assert + Assert.Equal(endpoint.AssetFile, taskItem.GetMetadataValueEscaped("AssetFile")); + Assert.NotEmpty(taskItem.GetMetadataValueEscaped("Selectors")); + Assert.NotEmpty(taskItem.GetMetadataValueEscaped("ResponseHeaders")); + Assert.NotEmpty(taskItem.GetMetadataValueEscaped("EndpointProperties")); + } + + [Fact] + public void ITaskItem2_SetMetadataValueLiteral_UpdatesProperties() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + var taskItem = (ITaskItem2)endpoint; + + // Act + taskItem.SetMetadataValueLiteral("AssetFile", "/new/path/asset.js"); + taskItem.SetMetadataValueLiteral("Selectors", "[{\"Name\":\"Test\",\"Value\":\"value\",\"Quality\":\"\"}]"); + + // Assert + Assert.Equal("/new/path/asset.js", endpoint.AssetFile); + Assert.Single(endpoint.Selectors); + Assert.Equal("Test", endpoint.Selectors[0].Name); + } + + [Fact] + public void StringSerialization_MaintainsSortingConsistency() + { + // Arrange + var endpoint = new StaticWebAssetEndpoint(); + + // Unsorted input arrays + var selectors = new[] + { + new StaticWebAssetEndpointSelector { Name = "Z-Selector", Value = "value1", Quality = "" }, + new StaticWebAssetEndpointSelector { Name = "A-Selector", Value = "value2", Quality = "0.8" } + }; + + var headers = new[] + { + new StaticWebAssetEndpointResponseHeader { Name = "Z-Header", Value = "value1" }, + new StaticWebAssetEndpointResponseHeader { Name = "A-Header", Value = "value2" } + }; + + var properties = new[] + { + new StaticWebAssetEndpointProperty { Name = "z-property", Value = "value1" }, + new StaticWebAssetEndpointProperty { Name = "a-property", Value = "value2" } + }; + + // Act - Set via array properties (should sort) + endpoint.Selectors = selectors; + endpoint.ResponseHeaders = headers; + endpoint.EndpointProperties = properties; + + // Get string representations + var selectorsString = ((ITaskItem2)endpoint).GetMetadataValueEscaped("Selectors"); + var headersString = ((ITaskItem2)endpoint).GetMetadataValueEscaped("ResponseHeaders"); + var propertiesString = ((ITaskItem2)endpoint).GetMetadataValueEscaped("EndpointProperties"); + + // Create new endpoint from strings + var endpoint2 = new StaticWebAssetEndpoint(); + endpoint2.SetSelectorsString(selectorsString); + endpoint2.SetResponseHeadersString(headersString); + endpoint2.SetEndpointPropertiesString(propertiesString); + + // Assert - Both endpoints should be equal and have sorted data + Assert.True(endpoint.Equals(endpoint2)); + + // Check that arrays are sorted + Assert.Equal("A-Selector", endpoint.Selectors[0].Name); + Assert.Equal("Z-Selector", endpoint.Selectors[1].Name); + Assert.Equal("A-Header", endpoint.ResponseHeaders[0].Name); + Assert.Equal("Z-Header", endpoint.ResponseHeaders[1].Name); + Assert.Equal("a-property", endpoint.EndpointProperties[0].Name); + Assert.Equal("z-property", endpoint.EndpointProperties[1].Name); + + // Check that deserialized arrays are also sorted + Assert.Equal("A-Selector", endpoint2.Selectors[0].Name); + Assert.Equal("Z-Selector", endpoint2.Selectors[1].Name); + Assert.Equal("A-Header", endpoint2.ResponseHeaders[0].Name); + Assert.Equal("Z-Header", endpoint2.ResponseHeaders[1].Name); + Assert.Equal("a-property", endpoint2.EndpointProperties[0].Name); + Assert.Equal("z-property", endpoint2.EndpointProperties[1].Name); + } + + [Fact] + public void FromMetadataValue_AndToMetadataValue_RoundTrip() + { + // Arrange + var originalSelectors = new[] + { + new StaticWebAssetEndpointSelector { Name = "Content-Type", Value = "text/javascript", Quality = "" }, + new StaticWebAssetEndpointSelector { Name = "Content-Encoding", Value = "gzip", Quality = "0.8" } + }; + + // Act - Serialize to string + var json = StaticWebAssetEndpointSelector.ToMetadataValue(originalSelectors); + + // Deserialize back to array + var deserializedSelectors = StaticWebAssetEndpointSelector.FromMetadataValue(json); + + // Assert + Assert.Equal(originalSelectors.Length, deserializedSelectors.Length); + for (int i = 0; i < originalSelectors.Length; i++) + { + Assert.Equal(originalSelectors[i].Name, deserializedSelectors[i].Name); + Assert.Equal(originalSelectors[i].Value, deserializedSelectors[i].Value); + Assert.Equal(originalSelectors[i].Quality, deserializedSelectors[i].Quality); + } + } + + private static StaticWebAssetEndpoint CreateTestEndpoint() + { + var endpoint = new StaticWebAssetEndpoint + { + Route = "test-route.js", + AssetFile = "/path/to/asset.js" + }; + + endpoint.Selectors = new[] + { + new StaticWebAssetEndpointSelector { Name = "Content-Type", Value = "text/javascript", Quality = "" } + }; + + endpoint.ResponseHeaders = new[] + { + new StaticWebAssetEndpointResponseHeader { Name = "Cache-Control", Value = "max-age=31536000" } + }; + + endpoint.EndpointProperties = new[] + { + new StaticWebAssetEndpointProperty { Name = "integrity", Value = "sha256-test" } + }; + + return endpoint; + } +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointPropertyNamesTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointPropertyNamesTest.cs new file mode 100644 index 000000000000..4ea652100da2 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointPropertyNamesTest.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class WellKnownEndpointPropertyNamesTest +{ + [Theory] + [InlineData("label")] + [InlineData("integrity")] + public void TryGetInternedPropertyName_KnownPropertyNames_ReturnsInternedStrings(string propertyName) + { + // Arrange + var span = ConvertToUtf8Span(propertyName); + + // Act + var result = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(span); + + // Assert + result.Should().NotBeNull(); + result.Should().Be(propertyName); + } + + [Theory] + [InlineData("label")] + [InlineData("integrity")] + public void TryGetInternedPropertyName_KnownPropertyNames_ReturnsSameReference(string input) + { + // Arrange + var span = ConvertToUtf8Span(input); + + // Act + var result1 = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(span); + var result2 = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(span); + + // Assert + result1.Should().NotBeNull("method should recognize the well-known property name"); + result2.Should().NotBeNull("method should recognize the well-known property name"); + result1.Should().BeSameAs(result2, "multiple calls with the same input should return the same reference"); + } + + [Theory] + [InlineData("unknown")] + [InlineData("Label")] // Different casing + [InlineData("INTEGRITY")] // Different casing + [InlineData("lab")] // Too short + [InlineData("labels")] // Too long + [InlineData("")] + [InlineData("integrit")] // Too short by one + [InlineData("integrityx")] // Too long by one + public void TryGetInternedPropertyName_UnknownPropertyNames_ReturnsNull(string propertyName) + { + // Arrange + var span = ConvertToUtf8Span(propertyName); + + // Act + var result = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(span); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetInternedPropertyName_EmptySpan_ReturnsNull() + { + // Arrange + var span = ReadOnlySpan.Empty; + + // Act + var result = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(span); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData("labe1")] // Similar to "label" but with number + [InlineData("integral")] // Similar to "integrity" but different + [InlineData(" ")] // Whitespace + public void TryGetInternedPropertyName_SimilarButDifferentNames_ReturnsNull(string propertyName) + { + // Arrange + var span = ConvertToUtf8Span(propertyName); + + // Act + var result = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(span); + + // Assert + result.Should().BeNull(); + } + + // Helper method + private static ReadOnlySpan ConvertToUtf8Span(string value) + => Encoding.UTF8.GetBytes(value); +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointSelectorNamesTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointSelectorNamesTest.cs new file mode 100644 index 000000000000..fad4e1a06d15 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointSelectorNamesTest.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class WellKnownEndpointSelectorNamesTest +{ + [Theory] + [InlineData("Content-Encoding")] + public void TryGetInternedSelectorName_KnownSelectorNames_ReturnsSameReference(string input) + { + // Arrange + var span = ConvertToUtf8Span(input); + + // Act + var result1 = WellKnownEndpointSelectorNames.TryGetInternedSelectorName(span); + var result2 = WellKnownEndpointSelectorNames.TryGetInternedSelectorName(span); + + // Assert + result1.Should().NotBeNull("method should recognize the well-known endpoint selector name"); + result2.Should().NotBeNull("method should recognize the well-known endpoint selector name"); + result1.Should().BeSameAs(result2, "multiple calls with the same input should return the same reference"); + } + + [Theory] + [InlineData("unknown")] + [InlineData("accept-encoding")] // Different casing + [InlineData("ACCEPT-ENCODING")] // Different casing + [InlineData("content-encoding")] // Different casing + [InlineData("CONTENT-ENCODING")] // Different casing + [InlineData("Accept-Encoding")] // Different header + [InlineData("Content-Type")] // Different header + [InlineData("Content-Length")] // Different header + [InlineData("")] + [InlineData("Accept-Encodin")] // Too short by one + [InlineData("Accept-Encodingx")] // Too long by one + [InlineData("Content-Encodin")] // Too short by one + [InlineData("Content-Encodingx")] // Too long by one + public void TryGetInternedSelectorName_UnknownSelectorNames_ReturnsNull(string selectorName) + { + // Arrange + var span = ConvertToUtf8Span(selectorName); + + // Act + var result = WellKnownEndpointSelectorNames.TryGetInternedSelectorName(span); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetInternedSelectorName_EmptySpan_ReturnsNull() + { + // Arrange + var span = ReadOnlySpan.Empty; + + // Act + var result = WellKnownEndpointSelectorNames.TryGetInternedSelectorName(span); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData("Accept_Encoding")] // Underscore instead of hyphen + [InlineData("AcceptEncoding")] // No hyphen + [InlineData("Accept Encoding")] // Space instead of hyphen + [InlineData("Content_Encoding")] // Underscore instead of hyphen + [InlineData("ContentEncoding")] // No hyphen + [InlineData("Content Encoding")] // Space instead of hyphen + public void TryGetInternedSelectorName_SimilarButDifferentNames_ReturnsNull(string selectorName) + { + // Arrange + var span = ConvertToUtf8Span(selectorName); + + // Act + var result = WellKnownEndpointSelectorNames.TryGetInternedSelectorName(span); + + // Assert + result.Should().BeNull(); + } + + // Helper method + private static ReadOnlySpan ConvertToUtf8Span(string value) + => Encoding.UTF8.GetBytes(value); +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointSelectorValuesTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointSelectorValuesTest.cs new file mode 100644 index 000000000000..290f5bbad7f8 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownEndpointSelectorValuesTest.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class WellKnownEndpointSelectorValuesTest +{ + [Theory] + [InlineData("gzip")] + [InlineData("br")] + public void TryGetInternedSelectorValue_KnownSelectorValues_ReturnsInternedStrings(string selectorValue) + { + // Arrange + var span = ConvertToUtf8Span(selectorValue); + + // Act + var result = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(span); + + // Assert + result.Should().NotBeNull(); + result.Should().Be(selectorValue); + } + + [Theory] + [InlineData("gzip")] + [InlineData("br")] + public void TryGetInternedSelectorValue_KnownSelectorValues_ReturnsSameReference(string input) + { + // Arrange + var span = ConvertToUtf8Span(input); + + // Act + var result1 = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(span); + var result2 = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(span); + + // Assert + result1.Should().NotBeNull("method should recognize the well-known selector value"); + result2.Should().NotBeNull("method should recognize the well-known selector value"); + result1.Should().BeSameAs(result2, "multiple calls with the same input should return the same reference"); + } + + [Theory] + [InlineData("unknown")] + [InlineData("GZIP")] // Different casing + [InlineData("BR")] // Different casing + [InlineData("Gzip")] // Different casing + [InlineData("deflate")] // Different compression + [InlineData("")] + [InlineData("gz")] // Too short + [InlineData("gzipx")] // Too long + [InlineData("b")] // Too short + [InlineData("brx")] // Too long + public void TryGetInternedSelectorValue_UnknownSelectorValues_ReturnsNull(string selectorValue) + { + // Arrange + var span = ConvertToUtf8Span(selectorValue); + + // Act + var result = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(span); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetInternedSelectorValue_EmptySpan_ReturnsNull() + { + // Arrange + var span = ReadOnlySpan.Empty; + + // Act + var result = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(span); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData("gzlp")] // Similar to gzip but different + [InlineData("bt")] // Similar length to br but different + [InlineData("zip")] // Contains gzip letters but different + public void TryGetInternedSelectorValue_SimilarButDifferentValues_ReturnsNull(string selectorValue) + { + // Arrange + var span = ConvertToUtf8Span(selectorValue); + + // Act + var result = WellKnownEndpointSelectorValues.TryGetInternedSelectorValue(span); + + // Assert + result.Should().BeNull(); + } + + // Helper method + private static ReadOnlySpan ConvertToUtf8Span(string value) + => Encoding.UTF8.GetBytes(value); +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownResponseHeaderValuesTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownResponseHeaderValuesTest.cs new file mode 100644 index 000000000000..e2b46a8d0810 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownResponseHeaderValuesTest.cs @@ -0,0 +1,194 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class WellKnownResponseHeaderValuesTest +{ + [Theory] + [InlineData("bytes")] + [InlineData("no-cache")] + [InlineData("max-age=31536000, immutable")] + [InlineData("gzip")] + [InlineData("br")] + [InlineData("application/octet-stream")] + [InlineData("text/javascript")] + [InlineData("text/css")] + [InlineData("text/html")] + [InlineData("application/json")] + [InlineData("image/png")] + [InlineData("image/jpeg")] + [InlineData("image/svg+xml")] + [InlineData("Content-Encoding")] + public void TryGetInternedHeaderValue_KnownHeaderValues_ReturnsInternedStrings(string headerValue) + { + // Arrange + var span = ConvertToUtf8Span(headerValue); + + // Act + var result1 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + var result2 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + + // Assert + result1.Should().NotBeNull(); + result2.Should().NotBeNull(); + result1.Should().BeSameAs(result2, "multiple calls should return the same interned string instance"); + } + + [Theory] + [InlineData("bytes")] + [InlineData("no-cache")] + [InlineData("max-age=31536000, immutable")] + [InlineData("gzip")] + [InlineData("br")] + [InlineData("application/octet-stream")] + [InlineData("text/javascript")] + [InlineData("text/css")] + [InlineData("text/html")] + [InlineData("application/json")] + [InlineData("image/png")] + [InlineData("image/jpeg")] + [InlineData("image/svg+xml")] + [InlineData("Content-Encoding")] + public void TryGetInternedHeaderValue_KnownHeaderValues_ReturnsSameReference(string input) + { + // Arrange + var span = ConvertToUtf8Span(input); + + // Act + var result1 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + var result2 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + + // Assert + result1.Should().NotBeNull("method should recognize the well-known header value"); + result2.Should().NotBeNull("method should recognize the well-known header value"); + result1.Should().BeSameAs(result2, "multiple calls with the same input should return the same reference"); + } + + [Theory] + [InlineData("unknown")] + [InlineData("BYTES")] // Different casing + [InlineData("No-Cache")] // Different casing + [InlineData("TEXT/CSS")] // Different casing + [InlineData("application/XML")] // Different type + [InlineData("text/plain")] // Different type + [InlineData("")] + [InlineData("byte")] // Too short + [InlineData("bytesx")] // Too long + [InlineData("no-cach")] // Too short by one + [InlineData("no-cachex")] // Too long by one + [InlineData("deflate")] // Different compression + public void TryGetInternedHeaderValue_UnknownHeaderValues_ReturnsNull(string headerValue) + { + // Arrange + var span = ConvertToUtf8Span(headerValue); + + // Act + var result = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetInternedHeaderValue_EmptySpan_ReturnsNull() + { + // Arrange + var span = ReadOnlySpan.Empty; + + // Act + var result = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetInternedHeaderValue_UsingUtf8Literals_ReturnsInternedStrings() + { + // Test using UTF-8 literals directly for compile-time efficiency + + // Act & Assert + var gzipResult1 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue("gzip"u8); + var gzipResult2 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue("gzip"u8); + gzipResult1.Should().BeSameAs(gzipResult2); + gzipResult1.Should().BeSameAs(WellKnownResponseHeaderValues.Gzip); + + var cssResult1 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue("text/css"u8); + var cssResult2 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue("text/css"u8); + cssResult1.Should().BeSameAs(cssResult2); + cssResult1.Should().BeSameAs(WellKnownResponseHeaderValues.TextCss); + + var jsonResult1 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue("application/json"u8); + var jsonResult2 = WellKnownResponseHeaderValues.TryGetInternedHeaderValue("application/json"u8); + jsonResult1.Should().BeSameAs(jsonResult2); + jsonResult1.Should().BeSameAs(WellKnownResponseHeaderValues.ApplicationJson); + } + + [Fact] + public void Debug_WellKnownResponseHeaderValues_CheckReferences() + { + // Debug test to understand what's happening + var gzipSpan = ConvertToUtf8Span("gzip"); + var result = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(gzipSpan); + + // Let's debug the actual results + result.Should().NotBeNull("method should find gzip"); + result.Should().Be("gzip", "content should match"); + + // Check if we're getting the right reference + var isExpectedReference = ReferenceEquals(result, WellKnownResponseHeaderValues.Gzip); + isExpectedReference.Should().BeTrue($"Expected reference to be the same. Got: '{result}', Expected: '{WellKnownResponseHeaderValues.Gzip}'. ReferenceEquals: {isExpectedReference}"); + } + + [Theory] + [InlineData("text_css")] // Underscore instead of slash + [InlineData("textcss")] // No slash + [InlineData("text css")] // Space instead of slash + [InlineData("image-png")] // Hyphen instead of slash + [InlineData("applicationjson")] // No slash + [InlineData("no_cache")] // Underscore instead of hyphen + [InlineData("nocache")] // No hyphen + [InlineData("byte5")] // Similar but different + [InlineData("qzip")] // Similar but different + public void TryGetInternedHeaderValue_SimilarButDifferentValues_ReturnsNull(string headerValue) + { + // Arrange + var span = ConvertToUtf8Span(headerValue); + + // Act + var result = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData("max-age=31536000")] // Missing immutable part + [InlineData("max-age=31536000,immutable")] // Missing space + [InlineData("max-age=31536001, immutable")] // Different max-age value + public void TryGetInternedHeaderValue_SimilarCacheControlValues_ReturnsNull(string headerValue) + { + // Arrange + var span = ConvertToUtf8Span(headerValue); + + // Act + var result = WellKnownResponseHeaderValues.TryGetInternedHeaderValue(span); + + // Assert + result.Should().BeNull(); + } + + // Helper method + private static ReadOnlySpan ConvertToUtf8Span(string value) + => Encoding.UTF8.GetBytes(value); + + // Helper method for UTF-8 literals - more efficient for compile-time known strings + private static ReadOnlySpan GetUtf8Span(ReadOnlySpan utf8Literal) + => utf8Literal; +} diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownResponseHeadersTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownResponseHeadersTest.cs new file mode 100644 index 000000000000..136129dc7d49 --- /dev/null +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/WellKnownResponseHeadersTest.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using Xunit; + +namespace Microsoft.NET.Sdk.StaticWebAssets.Tests.StaticWebAssets; + +public class WellKnownResponseHeadersTest +{ + [Theory] + [InlineData("Accept-Ranges")] + [InlineData("Cache-Control")] + [InlineData("Content-Encoding")] + [InlineData("Content-Length")] + [InlineData("Content-Type")] + [InlineData("ETag")] + [InlineData("Last-Modified")] + [InlineData("Vary")] + public void TryGetInternedHeaderName_KnownHeaderNames_ReturnsInternedStrings(string headerName) + { + // Arrange + var span = ConvertToUtf8Span(headerName); + + // Act + var result = WellKnownResponseHeaders.TryGetInternedHeaderName(span); + + // Assert + result.Should().NotBeNull(); + result.Should().Be(headerName); + } + + [Theory] + [InlineData("Accept-Ranges")] + [InlineData("Cache-Control")] + [InlineData("Content-Encoding")] + [InlineData("Content-Length")] + [InlineData("Content-Type")] + [InlineData("ETag")] + [InlineData("Last-Modified")] + [InlineData("Vary")] + public void TryGetInternedHeaderName_KnownHeaderNames_ReturnsSameReference(string input) + { + // Arrange + var span = ConvertToUtf8Span(input); + + // Act + var result1 = WellKnownResponseHeaders.TryGetInternedHeaderName(span); + var result2 = WellKnownResponseHeaders.TryGetInternedHeaderName(span); + + // Assert + result1.Should().NotBeNull("method should recognize the well-known response header"); + result2.Should().NotBeNull("method should recognize the well-known response header"); + result1.Should().BeSameAs(result2, "multiple calls with the same input should return the same reference"); + } + + [Theory] + [InlineData("unknown")] + [InlineData("accept-ranges")] // Different casing + [InlineData("CACHE-CONTROL")] // Different casing + [InlineData("content-type")] // Different casing + [InlineData("Authorization")] // Different header + [InlineData("Host")] // Different header + [InlineData("")] + [InlineData("ETag2")] // Too long + [InlineData("ETa")] // Too short + [InlineData("Content-Typ")] // Too short by one + [InlineData("Content-Typex")] // Too long by one + public void TryGetInternedHeaderName_UnknownHeaderNames_ReturnsNull(string headerName) + { + // Arrange + var span = ConvertToUtf8Span(headerName); + + // Act + var result = WellKnownResponseHeaders.TryGetInternedHeaderName(span); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void TryGetInternedHeaderName_EmptySpan_ReturnsNull() + { + // Arrange + var span = ReadOnlySpan.Empty; + + // Act + var result = WellKnownResponseHeaders.TryGetInternedHeaderName(span); + + // Assert + result.Should().BeNull(); + } + + [Theory] + [InlineData("Accept_Ranges")] // Underscore instead of hyphen + [InlineData("AcceptRanges")] // No hyphen + [InlineData("Accept Ranges")] // Space instead of hyphen + [InlineData("CacheControl")] // No hyphen + [InlineData("ContentType")] // No hyphen + [InlineData("ETagx")] // Similar but different + [InlineData("xETag")] // Similar but different + [InlineData("Varx")] // Similar but different + public void TryGetInternedHeaderName_SimilarButDifferentNames_ReturnsNull(string headerName) + { + // Arrange + var span = ConvertToUtf8Span(headerName); + + // Act + var result = WellKnownResponseHeaders.TryGetInternedHeaderName(span); + + // Assert + result.Should().BeNull(); + } + + // Helper method + private static ReadOnlySpan ConvertToUtf8Span(string value) + => Encoding.UTF8.GetBytes(value); +}