diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index a0b6c63e08..43bd4ed45d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -511,6 +511,9 @@ "src/Tests/Services/UserService.cs", "src/Tests/Services/FakePhoneService.cs", "src/Tests/Services/FakeGoogleRecaptchaHttpClient.cs", + "src/Tests/Extensions/PlaywrightAssetCachingExtensions.cs", + "src/Tests/Extensions/PlaywrightCacheStorageExtensions.cs", + "src/Tests/Extensions/PlaywrightNetworkExtensions.cs", "src/Tests/Extensions/PlaywrightCacheExtensions.cs", "src/Tests/Extensions/PlaywrightHydrationExtensions.cs", "src/Tests/Extensions/BrowserContextExtensions.cs", diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightCacheExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightAssetCachingExtensions.cs similarity index 63% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightCacheExtensions.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightAssetCachingExtensions.cs index 2d42b91d73..86ee67b098 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightCacheExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightAssetCachingExtensions.cs @@ -3,9 +3,9 @@ namespace Boilerplate.Tests.Extensions; -public static partial class PlaywrightCacheExtensions +public static partial class PlaywrightAssetCachingExtensions { - private static readonly ConcurrentDictionary Headers)> cachedResponses = []; + public static ConcurrentDictionary Headers)> CachedResponses { get; } = []; public static Task EnableBlazorWasmCaching(this IPage page) => page.EnableAssetCaching(BlazorWasmRegex()); @@ -27,12 +27,12 @@ private static async Task CacheHandler(IRoute route) { var url = new Uri(route.Request.Url).PathAndQuery; - if (cachedResponses.TryGetValue(url, out var cachedResponse) is false) + if (CachedResponses.TryGetValue(url, out var cachedResponse) is false) { var response = await route.FetchAsync(); var body = await response.BodyAsync(); cachedResponse = (body, response.Headers); - cachedResponses[url] = cachedResponse; + CachedResponses[url] = cachedResponse; } await route.FulfillAsync(new RouteFulfillOptions @@ -43,10 +43,19 @@ await route.FulfillAsync(new RouteFulfillOptions }); } - public static void ClearCache() => cachedResponses.Clear(); + public static bool ContainsAsset(Regex regex) => CachedResponses.Keys.Any(regex.IsMatch); - public static bool ContainsAsset(Regex regex) => cachedResponses.Keys.Any(regex.IsMatch); + public static bool ContainsAsset(string url) => CachedResponses.Keys.Any(url.Contains); + public static void ClearBlazorWasmCache() => ClearCache(BlazorWasmRegex()); + + public static void ClearCache() => CachedResponses.Clear(); + + public static void ClearCache(Regex regex) => CachedResponses.Where(x => regex.IsMatch(x.Key)).ToList().ForEach(key => CachedResponses.TryRemove(key)); + + public static void ClearCache(string url) => CachedResponses.Where(x => url.Contains(x.Key)).ToList().ForEach(key => CachedResponses.TryRemove(key)); + + //Glob pattern: /_framework/*.{wasm|pdb|dat}?v=sha256-* [GeneratedRegex(@"\/_framework\/[\w\.]+\.((wasm)|(pdb)|(dat))\?v=sha256-.+")] - private static partial Regex BlazorWasmRegex(); + public static partial Regex BlazorWasmRegex(); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightCacheStorageExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightCacheStorageExtensions.cs new file mode 100644 index 0000000000..2c3aecbeed --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightCacheStorageExtensions.cs @@ -0,0 +1,81 @@ +using System.Text.RegularExpressions; + +namespace Boilerplate.Tests.Extensions; + +public static class PlaywrightCacheStorageExtensions +{ + public static async Task DeleteCacheStorage(this IPage page, CacheId? cacheId = null) + { + // Chrome DevTools Protocol + // https://chromedevtools.github.io/devtools-protocol/tot/CacheStorage/ + + cacheId ??= new(); + await using var client = await page.Context.NewCDPSessionAsync(page); + await client.SendAsync("CacheStorage.deleteCache", new() { ["cacheId"] = cacheId.Value }); + } + + public static async Task GetCacheStorageEntries(this IPage page, CacheId? cacheId = null) + { + cacheId ??= new(); + await using var client = await page.Context.NewCDPSessionAsync(page); + var json = await client.SendAsync("CacheStorage.requestEntries", new() { ["cacheId"] = cacheId.Value }); + return json.Value.Deserialize()!; + } +} + +public record CacheId(string Origin = "http://localhost:5000/", string CacheName = "dotnet-resources-/") +{ + public string Value { get; init; } = $"{Origin}|{CacheName}"; + + public override string ToString() => Value; +} + +public class CacheEntries +{ + [JsonPropertyName("cacheDataEntries")] + public List CacheDataEntries { get; set; } = []; + + public List GetCacheEntries(Regex regex) => CacheDataEntries.Where(x => regex.IsMatch(x.RequestURL)).ToList(); + + public List GetCacheEntries(string url) => CacheDataEntries.Where(x => url.Contains(x.RequestURL)).ToList(); + + public bool ContainsCacheEntry(Regex regex) => CacheDataEntries.Exists(x => regex.IsMatch(x.RequestURL)); + + public bool ContainsCacheEntry(string url) => CacheDataEntries.Exists(x => url.Contains(x.RequestURL)); +} + +public class CacheEntry +{ + [JsonPropertyName("requestURL")] + public string RequestURL { get; set; } + + [JsonPropertyName("requestMethod")] + public string RequestMethod { get; set; } + + [JsonPropertyName("responseTime")] + public double ResponseTime { get; set; } + + [JsonPropertyName("responseStatus")] + public int ResponseStatus { get; set; } + + [JsonPropertyName("responseStatusText")] + public string ResponseStatusText { get; set; } + + [JsonPropertyName("responseType")] + public string ResponseType { get; set; } + + [JsonPropertyName("requestHeaders")] + public List RequestHeaders { get; set; } + + [JsonPropertyName("responseHeaders")] + public List ResponseHeaders { get; set; } +} + +public class RequestResponseHeader +{ + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightNetworkExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightNetworkExtensions.cs new file mode 100644 index 0000000000..a9f9285cdd --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Extensions/PlaywrightNetworkExtensions.cs @@ -0,0 +1,320 @@ +using System.Text.RegularExpressions; + +namespace Boilerplate.Tests.Extensions; + +public static class PlaywrightNetworkExtensions +{ + public static async Task OpenNetworkSession(this IPage page) + { + // Enable CDP session to access network details + var client = await page.Context.NewCDPSessionAsync(page); + var networkSession = new PlaywrightNetworkSession(client); + await networkSession.Enable(); + return networkSession; + } +} + +public class PlaywrightNetworkSession : IAsyncDisposable +{ + private readonly ICDPSession client; + private readonly ICDPSessionEvent responseReceived; + private readonly ICDPSessionEvent loadingFinished; + + public List DownloadedResponses { get; } = []; + public int TotalDownloaded => DownloadedResponses.Sum(x => x.EncodedDataLength); + + public PlaywrightNetworkSession(ICDPSession client) + { + this.client = client; + + // Chrome DevTools Protocol + // https://chromedevtools.github.io/devtools-protocol/1-3/Network/ + + // Listen to the 'Network.responseReceived' events + responseReceived = client.Event("Network.responseReceived"); + responseReceived.OnEvent += OnResponseReceived; + + // Listen to the 'Network.loadingFinished' events + loadingFinished = client.Event("Network.loadingFinished"); + loadingFinished.OnEvent += OnLoadingFinished; + } + + /// + /// Enables network tracking, network events will now be delivered to the client. + /// + public async Task Enable() => client.SendAsync("Network.enable"); + + /// + /// Disables network tracking, prevents network events from being sent to the client. + /// + public async Task Disable() => client.SendAsync("Network.disable"); + + private async void OnResponseReceived(object? sender, JsonElement? arg) + { + try + { + var data = arg!.Value.Deserialize()!; + data.Response.RequestId = data.RequestId; + DownloadedResponses.Add(data.Response); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to retrieve response data: {ex.Message}"); + } + } + + private async void OnLoadingFinished(object? sender, JsonElement? arg) + { + try + { + var data = arg!.Value.Deserialize()!; + var response = DownloadedResponses.Find(x => x.RequestId == data.RequestId)!; + response.EncodedDataLength = data.EncodedDataLength; + } + catch (Exception ex) + { + Console.WriteLine($"Failed to retrieve encoded size: {ex.Message}"); + } + } + + #region Other Commands + + public Task ClearBrowserCache() => client.SendAsync("Network.clearBrowserCache"); + + public Task ClearBrowserCookies() => client.SendAsync("Network.clearBrowserCookies"); + + /// + /// Toggles ignoring cache for each request. If true, cache will not be used. + /// + public Task SetCacheDisabled(bool disabled) => client.SendAsync("Network.setCacheDisabled", new() { ["cacheDisabled"] = disabled }); + + /// + /// Activates emulation of network conditions. + /// + public Task EmulateNetworkConditions(NetworkCondition networkCondition) => + client.SendAsync("Network.emulateNetworkConditions", new() + { + ["offline"] = networkCondition.Offline, + ["latency"] = networkCondition.Latency, + ["downloadThroughput"] = networkCondition.DownloadThroughput, + ["uploadThroughput"] = networkCondition.UploadThroughput, + ["connectionType"] = networkCondition.ConnectionType + }); + + /// + /// Blocks URLs from loading. + /// + /// URL patterns to block. Wildcards ('*') are allowed. + public Task SetBlockedUrls(string[] urls) => client.SendAsync("Network.setBlockedURLs", new() { ["urls"] = urls }); + + #endregion + + public List GetResponses(Regex regex) => DownloadedResponses.Where(x => regex.IsMatch(x.Url)).ToList(); + + public List GetResponses(string url) => DownloadedResponses.Where(x => url.Contains(x.Url)).ToList(); + + public bool ContainsResponse(Regex regex) => DownloadedResponses.Exists(x => regex.IsMatch(x.Url)); + + public bool ContainsResponse(string url) => DownloadedResponses.Exists(x => url.Contains(x.Url)); + + public async ValueTask DisposeAsync() + { + responseReceived.OnEvent -= OnResponseReceived; + loadingFinished.OnEvent -= OnLoadingFinished; + await client.DisposeAsync(); + } + + private class LoadingFinishedData + { + [JsonPropertyName("requestId")] + public string RequestId { get; set; } + + [JsonPropertyName("timestamp")] + public double Timestamp { get; set; } + + [JsonPropertyName("encodedDataLength")] + public int EncodedDataLength { get; set; } + } + + private class ResponseReceivedData + { + [JsonPropertyName("requestId")] + public string RequestId { get; set; } + + [JsonPropertyName("loaderId")] + public string LoaderId { get; set; } + + [JsonPropertyName("timestamp")] + public double Timestamp { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("response")] + public DownloadedResponse Response { get; set; } + } +} + +public class DownloadedResponse : IEquatable +{ + public string RequestId { get; set; } + + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("statusText")] + public string StatusText { get; set; } + + [JsonPropertyName("headers")] + public Dictionary Headers { get; set; } + + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } + + [JsonPropertyName("charset")] + public string Charset { get; set; } + + [JsonPropertyName("remoteIPAddress")] + public string RemoteIPAddress { get; set; } + + [JsonPropertyName("remotePort")] + public int RemotePort { get; set; } + + [JsonPropertyName("fromDiskCache")] + public bool FromDiskCache { get; set; } + + [JsonPropertyName("fromServiceWorker")] + public bool FromServiceWorker { get; set; } + + [JsonPropertyName("fromPrefetchCache")] + public bool FromPrefetchCache { get; set; } + + [JsonPropertyName("encodedDataLength")] + public int EncodedDataLength { get; set; } + + [JsonPropertyName("responseTime")] + public double ResponseTime { get; set; } + + [JsonPropertyName("protocol")] + public string Protocol { get; set; } + + public override int GetHashCode() => RequestId.GetHashCode(); + public override bool Equals(object? obj) => obj is DownloadedResponse other && Equals(other); + public bool Equals(DownloadedResponse? other) => other is not null && RequestId == other.RequestId; +} + +public class NetworkCondition +{ + /// + /// True to emulate Internet disconnection. + /// + public bool Offline { get; set; } + + /// + /// Minimum latency from request sent to response headers received(ms). + /// + public int Latency { get; set; } + + /// + /// Maximal aggregated download throughput(bytes/sec). -1 disables download throttling. + /// + public int DownloadThroughput { get; set; } + + /// + /// Maximal aggregated upload throughput(bytes/sec). -1 disables upload throttling. + /// + public int UploadThroughput { get; set; } + + /// + /// The underlying connection technology that the browser is supposedly using. + /// + public ConnectionType ConnectionType { get; set; } + + public static readonly NetworkCondition IsOffline = new() + { + Offline = true, + DownloadThroughput = 0, + UploadThroughput = 0, + Latency = 0, + ConnectionType = ConnectionType.None + }; + + public static readonly NetworkCondition NoThrottle = new() + { + Offline = false, + DownloadThroughput = -1, + UploadThroughput = -1, + Latency = 0, + ConnectionType = ConnectionType.None + }; + + public static readonly NetworkCondition Regular2G = new() + { + Offline = false, + DownloadThroughput = (250 * 1024) / 8, + UploadThroughput = (50 * 1024) / 8, + Latency = 300, + ConnectionType = ConnectionType.Cellular2G + }; + + public static readonly NetworkCondition Fast2G = new() + { + Offline = false, + DownloadThroughput = (450 * 1024) / 8, + UploadThroughput = (150 * 1024) / 8, + Latency = 150, + ConnectionType = ConnectionType.Cellular2G + }; + + public static readonly NetworkCondition Regular3G = new() + { + Offline = false, + DownloadThroughput = (750 * 1024) / 8, + UploadThroughput = (250 * 1024) / 8, + Latency = 100, + ConnectionType = ConnectionType.Cellular3G + }; + + public static readonly NetworkCondition Good3G = new() + { + Offline = false, + DownloadThroughput = (1500 * 1024) / 8, + UploadThroughput = (750 * 1024) / 8, + Latency = 40, + ConnectionType = ConnectionType.Cellular3G + }; + + public static readonly NetworkCondition Regular4G = new() + { + Offline = false, + DownloadThroughput = (4 * 1024 * 1024) / 8, + UploadThroughput = (3 * 1024 * 1024) / 8, + Latency = 20, + ConnectionType = ConnectionType.Cellular4G + }; + + public static readonly NetworkCondition WiFi = new() + { + Offline = false, + DownloadThroughput = (30 * 1024 * 1024) / 8, + UploadThroughput = (15 * 1024 * 1024) / 8, + Latency = 2, + ConnectionType = ConnectionType.Wifi + }; +} + +public enum ConnectionType +{ + None, + Cellular2G, + Cellular3G, + Cellular4G, + Bluetooth, + Ethernet, + Wifi, + Wimax, + Other +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/BlazorWebAssemblyTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/BlazorWebAssemblyTests.cs index b39c56c77e..8184ab661c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/BlazorWebAssemblyTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/BlazorWebAssemblyTests.cs @@ -1,4 +1,8 @@ -using Boilerplate.Client.Web; +//+:cnd:noEmit +using Boilerplate.Client.Web; +using Boilerplate.Tests.Extensions; +using Boilerplate.Tests.PageTests.PageModels; +using Boilerplate.Tests.PageTests.PageModels.Layout; namespace Boilerplate.Tests.PageTests.BlazorWebAssembly; @@ -13,17 +17,85 @@ public partial class LocalizationTests : BlazorServer.LocalizationTests { public override BlazorWebAppMode BlazorRenderMode => BlazorWebAppMode.BlazorWebAssembly; -#if MultilingualEnabled == false [TestMethod] [TestCategory("MultilingualDisabled")] public async Task MultilingualDisabled() { - var homePage = new PageModels.MainHomePage(Page, WebAppServerAddress); + if (CultureInfoManager.MultilingualEnabled is false) + { + Assert.Inconclusive("Multilingual is disabled. " + + "You can enable it via true setting in Directiory.Build.props."); + return; + } + + var homePage = new MainHomePage(Page, WebAppServerAddress); await homePage.Open(); await homePage.AssertOpen(); - var contains = Extensions.PlaywrightCacheExtensions.ContainsAsset(new(@"\/_framework\/icudt_hybrid\.dat\?v=sha256-.+")); + var contains = PlaywrightAssetCachingExtensions.ContainsAsset("icudt_hybrid.dat"); Assert.IsFalse(contains, "The 'icudt_hybrid.dat' file must not be loaded when Multilingual is disabled."); } -#endif +} + +[TestClass] +public partial class DownloadSizeTests : PageTestBase +{ + public override BlazorWebAppMode BlazorRenderMode => BlazorWebAppMode.BlazorWebAssembly; + public override Uri WebAppServerAddress => new("http://localhost:5000/"); + public override bool EnableBlazorWasmCaching => false; + + //#if (sample == "Todo") + [TestMethod] + [AutoAuthenticate] + [AutoStartTestServer(false)] + public async Task TodoDownloadSize() + { + await AssertDownloadSize(new IdentityHomePage(Page, WebAppServerAddress), expectedTotalSizeKB: 3_200, expectedWasmSizeKB: 2_700); + } + //#elif (sample == "Admin") + [TestMethod] + [AutoAuthenticate] + [AutoStartTestServer(false)] + public async Task AdminDownloadSize() + { + await AssertDownloadSize(new IdentityHomePage(Page, WebAppServerAddress), expectedTotalSizeKB: 3_200, expectedWasmSizeKB: 2_700); + } + //#else + [TestMethod] + [AutoStartTestServer(false)] + public async Task SimpleDownloadSize() + { + await AssertDownloadSize(new MainHomePage(Page, WebAppServerAddress), expectedTotalSizeKB: 3_200, expectedWasmSizeKB: 2_700); + } + //#endif + + private async Task AssertDownloadSize(TPage page, int expectedTotalSizeKB, int expectedWasmSizeKB, int toleranceKB = 50) + where TPage : RootLayout + { + var downloadSizeTests = Environment.GetEnvironmentVariable(nameof(DownloadSizeTests)); + if (Convert.ToBoolean(downloadSizeTests) is false) + { + Assert.Inconclusive("Download size tests are disabled. " + + "You can enable it via an environment variable `DownloadSizeTests=true`"); + return; + } + + await using var networkSession = await Page.OpenNetworkSession(); + + await page.Open(); + await page.AssertOpen(); + + await Page.WaitForHydrationToComplete(); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var totalSizeKB = networkSession.TotalDownloaded / 1000; + var wasmSizeKB = networkSession.DownloadedResponses + .Where(x => PlaywrightAssetCachingExtensions.BlazorWasmRegex().IsMatch(new Uri(x.Url).PathAndQuery)) + .Sum(x => x.EncodedDataLength) / 1000; + + Assert.AreEqual(expectedTotalSizeKB, totalSizeKB, toleranceKB, "Total size is not within tolerance."); + Assert.AreEqual(expectedWasmSizeKB, wasmSizeKB, toleranceKB, "Wasm size is not within tolerance."); + + Console.WriteLine($"Total size: {totalSizeKB} KB"); + } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs index f77b645582..55ae2628e4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs @@ -10,7 +10,7 @@ public abstract partial class PageTestBase : PageTest { public AppTestServer TestServer { get; set; } = new(); public WebApplication WebApp => TestServer.WebApp; - public Uri WebAppServerAddress => TestServer.WebAppServerAddress; + public virtual Uri WebAppServerAddress => TestServer.WebAppServerAddress; public virtual BlazorWebAppMode BlazorRenderMode => BlazorWebAppMode.BlazorServer; public virtual bool PreRenderEnabled => false; public virtual bool EnableBlazorWasmCaching => true;