StaticWebAssetsBaselineComparer.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. // Licensed to the .NET Foundation under one or more agreements.
  2. // The .NET Foundation licenses this file to you under the MIT license.
  3. using System.Diagnostics;
  4. using System.Linq;
  5. using Microsoft.AspNetCore.StaticWebAssets.Tasks;
  6. using Microsoft.NET.Sdk.StaticWebAssets.Tasks;
  7. namespace Microsoft.NET.Sdk.Razor.Tests;
  8. public class StaticWebAssetsBaselineComparer
  9. {
  10. private static readonly string BaselineGenerationInstructions =
  11. @"If the difference in baselines is expected, please re-generate the baselines.
  12. Start by ensuring you're dogfooding the SDK from the current branch (dotnet --version should be '*.0.0-dev').
  13. If you're not on the dogfood sdk, from the root of the repository run:
  14. 1. dotnet clean
  15. 2. .\restore.cmd or ./restore.sh
  16. 3. .\build.cmd ./build.sh
  17. 4. .\eng\dogfood.cmd or . ./eng/dogfood.sh
  18. Then, using the dogfood SDK run the .\src\RazorSdk\update-test-baselines.ps1 script.";
  19. public static StaticWebAssetsBaselineComparer Instance { get; } = new();
  20. internal void AssertManifest(StaticWebAssetsManifest expected, StaticWebAssetsManifest actual)
  21. {
  22. //Many of the properties in the manifest contain full paths, to avoid flakiness on the tests, we don't compare the full paths.
  23. actual.Version.Should().Be(expected.Version);
  24. actual.Source.Should().Be(expected.Source);
  25. actual.BasePath.Should().Be(expected.BasePath);
  26. actual.Mode.Should().Be(expected.Mode);
  27. actual.ManifestType.Should().Be(expected.ManifestType);
  28. actual.ReferencedProjectsConfiguration.Should().HaveSameCount(expected.ReferencedProjectsConfiguration);
  29. // Relax the check for project reference configuration items see
  30. // https://github.com/dotnet/sdk/pull/27381#issuecomment-1228764471
  31. // for details.
  32. //manifest.ReferencedProjectsConfiguration.OrderBy(cm => cm.Identity)
  33. // .Should()
  34. // .BeEquivalentTo(expected.ReferencedProjectsConfiguration.OrderBy(cm => cm.Identity));
  35. actual.DiscoveryPatterns.OrderBy(dp => dp.Name).Should().BeEquivalentTo(expected.DiscoveryPatterns.OrderBy(dp => dp.Name));
  36. var actualAssets = actual.Assets
  37. .OrderBy(a => a.BasePath)
  38. .ThenBy(a => a.RelativePath)
  39. .ThenBy(a => a.AssetKind)
  40. .GroupBy(a => GetAssetGroup(a))
  41. .ToDictionary(a => a.Key, a => a.ToArray());
  42. var duplicateAssets = actual.Assets
  43. .GroupBy(a => a)
  44. .ToDictionary(a => a.Key, a => a.ToArray());
  45. var foundDuplicateAssetss = duplicateAssets.Where(a => a.Value.Length > 1).ToArray();
  46. duplicateAssets.Where(a => a.Value.Length > 1).Should().BeEmpty($@"no duplicate assets should exist. But found:
  47. {string.Join($"{Environment.NewLine} ", foundDuplicateAssetss.Select(a => @$"{a.Key.Identity} - {a.Value.Length}"))}{Environment.NewLine}");
  48. var expectedAssets = expected.Assets
  49. .OrderBy(a => a.BasePath)
  50. .ThenBy(a => a.RelativePath)
  51. .ThenBy(a => a.AssetKind)
  52. .GroupBy(a => GetAssetGroup(a))
  53. .ToDictionary(a => a.Key, a => a.ToArray());
  54. var actualAssetsByIdentity = actual.Assets.GroupBy(a => a.Identity).ToDictionary(a => a.Key, a => a.ToArray());
  55. foreach (var asset in actual.Assets)
  56. {
  57. if (!string.IsNullOrEmpty(asset.RelatedAsset))
  58. {
  59. actualAssetsByIdentity.Should().ContainKey(asset.RelatedAsset);
  60. }
  61. }
  62. foreach (var (group, actualAssetsGroup) in actualAssets)
  63. {
  64. var expectedAssetsGroup = expectedAssets[group];
  65. CompareAssetGroup(group, actualAssetsGroup, expectedAssetsGroup);
  66. }
  67. var actualEndpoints = actual.Endpoints
  68. .OrderBy(a => a.Route)
  69. .ThenBy(a => a.AssetFile)
  70. .GroupBy(a => GetEndpointGroup(a))
  71. .ToDictionary(a => a.Key, a => a.ToArray());
  72. SortEndpointProperties(actualEndpoints);
  73. var duplicateEndpoints = actual.Endpoints
  74. .GroupBy(a => a)
  75. .ToDictionary(a => a.Key, a => a.ToArray());
  76. var foundDuplicateEndpoints = duplicateEndpoints.Where(a => DuplicatesExist(a)).ToArray();
  77. duplicateEndpoints.Where(a => DuplicatesExist(a)).Should().BeEmpty($@"no duplicate endpoints should exist. But found:
  78. {string.Join($"{Environment.NewLine} ", foundDuplicateEndpoints.Select(a => @$"{a.Key.Route} - {a.Key.AssetFile} - {a.Key.Selectors.Length} - {a.Value.Length}"))}{Environment.NewLine}");
  79. foreach (var endpoint in actual.Endpoints)
  80. {
  81. actualAssetsByIdentity.Should().ContainKey(endpoint.AssetFile);
  82. }
  83. var expectedEndpoints = expected.Endpoints
  84. .OrderBy(a => a.Route)
  85. .ThenBy(a => a.AssetFile)
  86. .GroupBy(a => GetEndpointGroup(a))
  87. .ToDictionary(a => a.Key, a => a.ToArray());
  88. SortEndpointProperties(expectedEndpoints);
  89. foreach (var (group, actualEndpointsGroup) in actualEndpoints)
  90. {
  91. var expectedEndpointsGroup = expectedEndpoints[group];
  92. CompareEndpointGroup(group, actualEndpointsGroup, expectedEndpointsGroup);
  93. }
  94. static bool DuplicatesExist(KeyValuePair<StaticWebAssetEndpoint, StaticWebAssetEndpoint[]> a)
  95. {
  96. var endpoint = a.Key;
  97. if (endpoint.Route.EndsWith(".gz") || endpoint.Route.EndsWith(".br") || endpoint.Selectors.Length == 1)
  98. {
  99. // This is not exact, but there are situations in which our templatization process is not biyective and Build and Publish assets defined during build for
  100. // the same asset end up having the same endpoint. To avoid issues with this, we relax the check to support finding more than one.
  101. return a.Value.Length > 2;
  102. }
  103. else
  104. {
  105. return a.Value.Length > 1;
  106. }
  107. }
  108. }
  109. private static void SortEndpointProperties(Dictionary<string, StaticWebAssetEndpoint[]> endpoints)
  110. {
  111. foreach (var endpointGroup in endpoints.Values)
  112. {
  113. foreach (var endpoint in endpointGroup)
  114. {
  115. Array.Sort(endpoint.Selectors, (a, b) => (a.Name, a.Value).CompareTo((b.Name, b.Value)));
  116. Array.Sort(endpoint.ResponseHeaders, (a, b) => (a.Name, a.Value).CompareTo((b.Name, b.Value)));
  117. Array.Sort(endpoint.EndpointProperties, (a, b) => (a.Name, a.Value).CompareTo((b.Name, b.Value)));
  118. }
  119. }
  120. }
  121. protected virtual void CompareAssetGroup(string group, StaticWebAsset[] manifestAssets, StaticWebAsset[] expectedAssets)
  122. {
  123. var comparisonMode = CompareAssetCounts(group, manifestAssets, expectedAssets);
  124. // Otherwise, do a property level comparison of all assets
  125. switch (comparisonMode)
  126. {
  127. case GroupComparisonMode.Exact:
  128. break;
  129. case GroupComparisonMode.AllowAdditionalAssets:
  130. break;
  131. default:
  132. break;
  133. }
  134. var differences = new List<string>();
  135. var assetDifferences = new List<string>();
  136. var groupLength = Math.Min(manifestAssets.Length, expectedAssets.Length);
  137. for (var i = 0; i < groupLength; i++)
  138. {
  139. var manifestAsset = manifestAssets[i];
  140. var expectedAsset = expectedAssets[i];
  141. ComputeAssetDifferences(assetDifferences, manifestAsset, expectedAsset);
  142. if (assetDifferences.Any())
  143. {
  144. differences.Add(@$"
  145. ==================================================
  146. For {expectedAsset.Identity}:
  147. {string.Join(Environment.NewLine, assetDifferences)}
  148. ==================================================");
  149. }
  150. assetDifferences.Clear();
  151. }
  152. differences.Should().BeEmpty(
  153. @$" the generated manifest should match the expected baseline.
  154. {BaselineGenerationInstructions}
  155. ");
  156. }
  157. private GroupComparisonMode CompareAssetCounts(string group, StaticWebAsset[] manifestAssets, StaticWebAsset[] expectedAssets)
  158. {
  159. var comparisonMode = GetGroupComparisonMode(group);
  160. // If there's a mismatch in the number of assets, just print the strict difference in the asset `Identity`
  161. switch (comparisonMode)
  162. {
  163. case GroupComparisonMode.Exact:
  164. if (manifestAssets.Length != expectedAssets.Length)
  165. {
  166. ThrowAssetCountMismatchError(manifestAssets, expectedAssets);
  167. }
  168. break;
  169. case GroupComparisonMode.AllowAdditionalAssets:
  170. if (expectedAssets.Except(manifestAssets).Any())
  171. {
  172. ThrowAssetCountMismatchError(manifestAssets, expectedAssets);
  173. }
  174. break;
  175. default:
  176. break;
  177. }
  178. return comparisonMode;
  179. static void ThrowAssetCountMismatchError(StaticWebAsset[] manifestAssets, StaticWebAsset[] expectedAssets)
  180. {
  181. var missingAssets = expectedAssets.Except(manifestAssets);
  182. var unexpectedAssets = manifestAssets.Except(expectedAssets);
  183. var differences = new List<string>();
  184. if (missingAssets.Any())
  185. {
  186. differences.Add($@"The following expected assets weren't found in the manifest:
  187. {string.Join($"{Environment.NewLine}\t", missingAssets.Select(a => a.Identity))}");
  188. }
  189. if (unexpectedAssets.Any())
  190. {
  191. differences.Add($@"The following additional unexpected assets were found in the manifest:
  192. {string.Join($"{Environment.NewLine}\t", unexpectedAssets.Select(a => a.Identity))}");
  193. }
  194. throw new Exception($@"{string.Join(Environment.NewLine, differences)}
  195. {BaselineGenerationInstructions}");
  196. }
  197. }
  198. protected virtual GroupComparisonMode GetGroupComparisonMode(string group)
  199. {
  200. return GroupComparisonMode.Exact;
  201. }
  202. private static void ComputeAssetDifferences(List<string> assetDifferences, StaticWebAsset manifestAsset, StaticWebAsset expectedAsset)
  203. {
  204. if (manifestAsset.Identity != expectedAsset.Identity)
  205. {
  206. assetDifferences.Add($"Expected manifest Identity of {expectedAsset.Identity} but found {manifestAsset.Identity}.");
  207. }
  208. if (manifestAsset.SourceType != expectedAsset.SourceType)
  209. {
  210. assetDifferences.Add($"Expected manifest SourceType of {expectedAsset.SourceType} but found {manifestAsset.SourceType}.");
  211. }
  212. if (manifestAsset.SourceId != expectedAsset.SourceId)
  213. {
  214. assetDifferences.Add($"Expected manifest SourceId of {expectedAsset.SourceId} but found {manifestAsset.SourceId}.");
  215. }
  216. if (manifestAsset.ContentRoot != expectedAsset.ContentRoot)
  217. {
  218. assetDifferences.Add($"Expected manifest ContentRoot of {expectedAsset.ContentRoot} but found {manifestAsset.ContentRoot}.");
  219. }
  220. if (manifestAsset.BasePath != expectedAsset.BasePath)
  221. {
  222. assetDifferences.Add($"Expected manifest BasePath of {expectedAsset.BasePath} but found {manifestAsset.BasePath}.");
  223. }
  224. if (manifestAsset.RelativePath != expectedAsset.RelativePath)
  225. {
  226. assetDifferences.Add($"Expected manifest RelativePath of {expectedAsset.RelativePath} but found {manifestAsset.RelativePath}.");
  227. }
  228. if (manifestAsset.AssetKind != expectedAsset.AssetKind)
  229. {
  230. assetDifferences.Add($"Expected manifest AssetKind of {expectedAsset.AssetKind} but found {manifestAsset.AssetKind}.");
  231. }
  232. if (manifestAsset.AssetMode != expectedAsset.AssetMode)
  233. {
  234. assetDifferences.Add($"Expected manifest AssetMode of {expectedAsset.AssetMode} but found {manifestAsset.AssetMode}.");
  235. }
  236. if (manifestAsset.AssetRole != expectedAsset.AssetRole)
  237. {
  238. assetDifferences.Add($"Expected manifest AssetRole of {expectedAsset.AssetRole} but found {manifestAsset.AssetRole}.");
  239. }
  240. if (manifestAsset.RelatedAsset != expectedAsset.RelatedAsset)
  241. {
  242. assetDifferences.Add($"Expected manifest RelatedAsset of {expectedAsset.RelatedAsset} but found {manifestAsset.RelatedAsset}.");
  243. }
  244. if (manifestAsset.AssetTraitName != expectedAsset.AssetTraitName)
  245. {
  246. assetDifferences.Add($"Expected manifest AssetTraitName of {expectedAsset.AssetTraitName} but found {manifestAsset.AssetTraitName}.");
  247. }
  248. if (manifestAsset.AssetTraitValue != expectedAsset.AssetTraitValue)
  249. {
  250. assetDifferences.Add($"Expected manifest AssetTraitValue of {expectedAsset.AssetTraitValue} but found {manifestAsset.AssetTraitValue}.");
  251. }
  252. if (manifestAsset.CopyToOutputDirectory != expectedAsset.CopyToOutputDirectory)
  253. {
  254. assetDifferences.Add($"Expected manifest CopyToOutputDirectory of {expectedAsset.CopyToOutputDirectory} but found {manifestAsset.CopyToOutputDirectory}.");
  255. }
  256. if (manifestAsset.CopyToPublishDirectory != expectedAsset.CopyToPublishDirectory)
  257. {
  258. assetDifferences.Add($"Expected manifest CopyToPublishDirectory of {expectedAsset.CopyToPublishDirectory} but found {manifestAsset.CopyToPublishDirectory}.");
  259. }
  260. if (manifestAsset.OriginalItemSpec != expectedAsset.OriginalItemSpec)
  261. {
  262. assetDifferences.Add($"Expected manifest OriginalItemSpec of {expectedAsset.OriginalItemSpec} but found {manifestAsset.OriginalItemSpec}.");
  263. }
  264. }
  265. protected virtual string GetAssetGroup(StaticWebAsset asset)
  266. {
  267. return Path.GetExtension(asset.Identity.TrimEnd(']'));
  268. }
  269. protected virtual void CompareEndpointGroup(string group, StaticWebAssetEndpoint[] manifestAssets, StaticWebAssetEndpoint[] expectedAssets)
  270. {
  271. var comparisonMode = CompareEndpointCounts(group, manifestAssets, expectedAssets);
  272. // Otherwise, do a property level comparison of all assets
  273. switch (comparisonMode)
  274. {
  275. case GroupComparisonMode.Exact:
  276. break;
  277. case GroupComparisonMode.AllowAdditionalAssets:
  278. break;
  279. default:
  280. break;
  281. }
  282. var differences = new List<string>();
  283. var assetDifferences = new List<string>();
  284. var groupLength = Math.Min(manifestAssets.Length, expectedAssets.Length);
  285. for (var i = 0; i < groupLength; i++)
  286. {
  287. var manifestAsset = manifestAssets[i];
  288. var expectedAsset = expectedAssets[i];
  289. ComputeEndpointDifferences(assetDifferences, manifestAsset, expectedAsset);
  290. if (assetDifferences.Any())
  291. {
  292. differences.Add(@$"
  293. ==================================================
  294. For {expectedAsset.AssetFile}:
  295. {string.Join(Environment.NewLine, assetDifferences)}
  296. ==================================================");
  297. }
  298. assetDifferences.Clear();
  299. }
  300. differences.Should().BeEmpty(
  301. @$" the generated manifest should match the expected baseline.
  302. {BaselineGenerationInstructions}
  303. ");
  304. }
  305. private GroupComparisonMode CompareEndpointCounts(string group, StaticWebAssetEndpoint[] manifestEndpoints, StaticWebAssetEndpoint[] expectedEndpoints)
  306. {
  307. var comparisonMode = GetGroupComparisonMode(group);
  308. // If there's a mismatch in the number of assets, just print the strict difference in the asset `Identity`
  309. switch (comparisonMode)
  310. {
  311. case GroupComparisonMode.Exact:
  312. if (manifestEndpoints.Length != expectedEndpoints.Length)
  313. {
  314. ThrowEndpointCountMismatchError(group, manifestEndpoints, expectedEndpoints);
  315. }
  316. break;
  317. case GroupComparisonMode.AllowAdditionalAssets:
  318. if (expectedEndpoints.Except(manifestEndpoints).Any())
  319. {
  320. ThrowEndpointCountMismatchError(group, manifestEndpoints, expectedEndpoints);
  321. }
  322. break;
  323. default:
  324. break;
  325. }
  326. return comparisonMode;
  327. static void ThrowEndpointCountMismatchError(string group, StaticWebAssetEndpoint[] manifestEndpoints, StaticWebAssetEndpoint[] expectedEndpoints)
  328. {
  329. var missingEndpoints = expectedEndpoints.Except(manifestEndpoints);
  330. var unexpectedEndpoints = manifestEndpoints.Except(expectedEndpoints);
  331. var differences = new List<string>
  332. {
  333. $"Expected group '{group}' to have '{expectedEndpoints.Length}' endpoints but found '{manifestEndpoints.Length}'.",
  334. "Expected Endpoints:",
  335. string.Join($"{Environment.NewLine}\t", expectedEndpoints.Select(a => $"{a.Route} - {a.Selectors.Length} - {a.AssetFile}")),
  336. "Actual Endpoints:",
  337. string.Join($"{Environment.NewLine}\t", manifestEndpoints.Select(a => $"{a.Route} - {a.Selectors.Length} - {a.AssetFile}"))
  338. };
  339. if (missingEndpoints.Any())
  340. {
  341. differences.Add($@"The following expected assets weren't found in the manifest:
  342. {string.Join($"{Environment.NewLine}\t", missingEndpoints.Select(a => $"{a.Route} - {a.AssetFile}"))}");
  343. }
  344. if (unexpectedEndpoints.Any())
  345. {
  346. differences.Add($@"The following additional unexpected assets were found in the manifest:
  347. {string.Join($"{Environment.NewLine}\t", unexpectedEndpoints.Select(a => $"{a.Route} - {a.AssetFile}"))}");
  348. }
  349. throw new Exception($@"{string.Join(Environment.NewLine, differences)}
  350. {BaselineGenerationInstructions}");
  351. }
  352. }
  353. protected virtual GroupComparisonMode GetAssetGroupComparisonMode(string group)
  354. {
  355. return GroupComparisonMode.Exact;
  356. }
  357. private static void ComputeEndpointDifferences(List<string> assetDifferences, StaticWebAssetEndpoint manifestAsset, StaticWebAssetEndpoint expectedAsset)
  358. {
  359. if (manifestAsset.Route != expectedAsset.Route)
  360. {
  361. assetDifferences.Add($"Expected manifest Identity of {expectedAsset.Route} but found {manifestAsset.Route}.");
  362. }
  363. if (manifestAsset.AssetFile != expectedAsset.AssetFile)
  364. {
  365. assetDifferences.Add($"Expected manifest SourceType of {expectedAsset.AssetFile} but found {manifestAsset.AssetFile}.");
  366. }
  367. ComputeSelectorDifferences(assetDifferences, manifestAsset.Selectors, expectedAsset.Selectors);
  368. ComputeResponseHeaderDifferences(assetDifferences, manifestAsset.ResponseHeaders, expectedAsset.ResponseHeaders);
  369. }
  370. private static void ComputeResponseHeaderDifferences(
  371. List<string> assetDifferences,
  372. StaticWebAssetEndpointResponseHeader[] manifestResponseHeaders,
  373. StaticWebAssetEndpointResponseHeader[] expectedResponseHeaders)
  374. {
  375. if (manifestResponseHeaders.Length != expectedResponseHeaders.Length)
  376. {
  377. assetDifferences.Add($"Expected manifest to have {expectedResponseHeaders.Length} response headers but found {manifestResponseHeaders.Length}.");
  378. }
  379. var manifest = new HashSet<StaticWebAssetEndpointResponseHeader>(manifestResponseHeaders);
  380. var differences = new HashSet<StaticWebAssetEndpointResponseHeader>(manifestResponseHeaders);
  381. var expected = new HashSet<StaticWebAssetEndpointResponseHeader>(expectedResponseHeaders);
  382. differences.SymmetricExceptWith(expected);
  383. foreach (var difference in differences)
  384. {
  385. if (!manifest.Contains(difference))
  386. {
  387. assetDifferences.Add($"Expected manifest to have response header '{difference.Name}={difference.Value}' but it was not found.");
  388. }
  389. else
  390. {
  391. assetDifferences.Add($"Found unexpected response header '{difference.Name}={difference.Value}'.");
  392. }
  393. }
  394. }
  395. private static void ComputeSelectorDifferences(
  396. List<string> assetDifferences,
  397. StaticWebAssetEndpointSelector[] manifestSelectors,
  398. StaticWebAssetEndpointSelector[] expectedSelectors)
  399. {
  400. if (manifestSelectors.Length != expectedSelectors.Length)
  401. {
  402. assetDifferences.Add($"Expected manifest to have {expectedSelectors.Length} selectors but found {manifestSelectors.Length}.");
  403. }
  404. var manifest = new HashSet<StaticWebAssetEndpointSelector>(manifestSelectors);
  405. var differences = new HashSet<StaticWebAssetEndpointSelector>(manifestSelectors);
  406. var expected = new HashSet<StaticWebAssetEndpointSelector>(expectedSelectors);
  407. differences.SymmetricExceptWith(expected);
  408. foreach (var difference in differences)
  409. {
  410. if (!manifest.Contains(difference))
  411. {
  412. assetDifferences.Add($"Expected manifest to have selector '{difference.Name}={difference.Value};q={difference.Quality}' but it was not found.");
  413. }
  414. else
  415. {
  416. assetDifferences.Add($"Found unexpected selector '{difference.Name}={difference.Value};q={difference.Quality}'.");
  417. }
  418. }
  419. }
  420. protected virtual string GetEndpointGroup(StaticWebAssetEndpoint asset)
  421. {
  422. return Path.GetExtension(asset.AssetFile.TrimEnd(']'));
  423. }
  424. }
  425. public enum GroupComparisonMode
  426. {
  427. // We require the same number of assets in a group for the baseline and the template.
  428. Exact,
  429. // We won't fail when we check against the baseline if additional assets are present for a group.
  430. AllowAdditionalAssets
  431. }