File: BrowserRefreshMiddlewareTest.cs
Web Access
Project: ..\..\..\test\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj (Microsoft.AspNetCore.Watch.BrowserRefresh.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Microsoft.AspNetCore.Watch.BrowserRefresh
{
    public class BrowserRefreshMiddlewareTest
    {
        [Theory]
        [InlineData("DELETE")]
        [InlineData("head")]
        [InlineData("Put")]
        public void IsBrowserDocumentRequest_ReturnsFalse_ForNonGetOrPostRequests(string method)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = method,
                    Headers =
                    {
                        ["Accept"] = "application/html",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Fact]
        public void IsBrowserDocumentRequest_ReturnsFalse_IsRequestDoesNotAcceptHtml()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "GET",
                    Headers =
                    {
                        ["Accept"] = "application/xml",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Fact]
        public void IsBrowserDocumentRequest_ReturnsTrue_ForGetRequestsThatAcceptHtml()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "GET",
                    Headers =
                    {
                        ["Accept"] = "application/json,text/html;q=0.9",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Fact]
        public void IsBrowserDocumentRequest_ReturnsTrue_ForRequestsThatAcceptAnyHtml()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "Post",
                    Headers =
                    {
                        ["Accept"] = "application/json,text/*+html;q=0.9",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Fact]
        public void IsBrowserDocumentRequest_ReturnsTrue_IfRequestDoesNotHaveFetchMetadataRequestHeader()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "GET",
                    Headers =
                    {
                        ["Accept"] = "text/html",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Fact]
        public void IsBrowserDocumentRequest_ReturnsTrue_IfRequestFetchMetadataRequestHeaderIsEmpty()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "Post",
                    Headers =
                    {
                        ["Accept"] = "text/html",
                        ["Sec-Fetch-Dest"] = string.Empty,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("document")]
        [InlineData("Document")]
        public void IsBrowserDocumentRequest_ReturnsTrue_IfRequestFetchMetadataRequestHeaderIsDocument(string headerValue)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "Post",
                    Headers =
                    {
                        ["Accept"] = "text/html",
                        ["Sec-Fetch-Dest"] = headerValue,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("frame")]
        [InlineData("iframe")]
        public void IsBrowserDocumentRequest_ReturnsTrue_IfRequestFetchMetadataRequestHeaderIsFrame(string headerValue)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "Post",
                    Headers =
                    {
                        ["Accept"] = "text/html",
                        ["Sec-Fetch-Dest"] = headerValue,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("serviceworker")]
        public void IsBrowserDocumentRequest_ReturnsFalse_IfRequestFetchMetadataRequestHeaderIsNotDocument(string headerValue)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "Post",
                    Headers =
                    {
                        ["Accept"] = "text/html",
                        ["Sec-Fetch-Dest"] = headerValue,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsBrowserDocumentRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Theory]
        [InlineData("DELETE")]
        [InlineData("POST")]
        [InlineData("head")]
        [InlineData("Put")]
        public void IsWebassemblyBootRequest_ReturnsFalse_ForNonGetRequests(string method)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = method,
                    Headers =
                    {
                        ["Accept"] = "application/html",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Fact]
        public void IsWebassemblyBootRequest_ReturnsFalse_IfRequestDoesNotAcceptJson()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "text/html",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Fact]
        public void IsWebassemblyBootRequest_ReturnsTrue_ForGetRequestsThatAcceptJson()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "text/html,application/json;q=0.9",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Fact]
        public void IsWebassemblyBootRequest_ReturnsTrue_ForGetRequestsThatAcceptAnyContentType()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "*/*",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("/_framework/blazor.boot.json")]
        [InlineData("/Blazor.boot.json")]
        public void IsWebassemblyBootRequest_ReturnsTrue_ForFileNameRequestsToBlazorBootJson(string path)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = path,
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "application/json",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("/_framework/other.txt")]
        [InlineData("/other.txt")]
        [InlineData("/Blazor.boot.json/other.txt")]
        public void IsWebassemblyBootRequest_ReturnsFalse_ForRequestsToOtherPathsThanBlazorBootJson(string path)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = path,
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "application/json",
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Fact]
        public void IsWebassemblyBootRequest_ReturnsTrue_IfRequestDoesNotHaveFetchMetadataRequestHeader()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "application/json"
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Fact]
        public void IsWebassemblyBootRequest_ReturnsTrue_IfRequestFetchMetadataRequestHeaderIsEmpty()
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "application/json",
                        ["Sec-Fetch-Dest"] = string.Empty,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("empty")]
        [InlineData("Empty")]
        public void IsWebassemblyBootRequest_ReturnsTrue_IfRequestFetchMetadataRequestHeaderIsEmptyValue(string headerValue)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "application/json",
                        ["Sec-Fetch-Dest"] = headerValue,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.True(result);
        }
 
        [Theory]
        [InlineData("frame")]
        [InlineData("iframe")]
        [InlineData("serviceworker")]
        [InlineData("document")]
        public void IsWebassemblyBootRequest_ReturnsFalse_IfRequestFetchMetadataRequestHeaderIsEmptyValue(string headerValue)
        {
            // Arrange
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = HttpMethods.Get,
                    Headers =
                    {
                        ["Accept"] = "application/json",
                        ["Sec-Fetch-Dest"] = headerValue,
                    },
                },
            };
 
            // Act
            var result = BrowserRefreshMiddleware.IsWebAssemblyBootRequest(context);
 
            // Assert
            Assert.False(result);
        }
 
        [Fact]
        public async Task InvokeAsync_AttachesHeadersToResponse()
        {
            var stream = new MemoryStream();
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = "GET",
                    Headers = { ["Accept"] = "application/json" },
                },
                Response =
                {
                    Body = stream
                },
            };
 
            var response = new TestHttpResponseFeature
            {
                Body = stream,
                Headers = new HeaderDictionary()
            };
            context.Features.Set<IHttpResponseFeature>(response);
            context.Features.Set<IHttpResponseBodyFeature>(response);
 
            var middleware = new BrowserRefreshMiddleware(async (context) =>
            {
                context.Response.ContentType = "application/json";
                await context.Response.StartAsync();
                await context.Response.WriteAsync("{ }");
            }, NullLogger<BrowserRefreshMiddleware>.Instance);
 
            middleware.Test_SetEnvironment(dotnetModifiableAssemblies: "true", aspnetcoreBrowserTools: "true");
 
            // Act
            await middleware.InvokeAsync(context);
 
            // Assert
            Assert.True(context.Response.Headers.ContainsKey("DOTNET-MODIFIABLE-ASSEMBLIES"));
            Assert.True(context.Response.Headers.ContainsKey("ASPNETCORE-BROWSER-TOOLS"));
        }
 
        [Fact]
        public async Task InvokeAsync_DoesNotAttachHeaders_WhenAlreadyAttached()
        {
            var stream = new MemoryStream();
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Path = "/_framework/blazor.boot.json",
                    Method = "GET",
                    Headers = { ["Accept"] = "application/json" },
                },
                Response =
                {
                    Body = stream
                },
            };
 
            var response = new TestHttpResponseFeature
            {
                Body = stream,
                Headers = new HeaderDictionary()
            };
            context.Features.Set<IHttpResponseFeature>(response);
            context.Features.Set<IHttpResponseBodyFeature>(response);
 
            var middleware = new BrowserRefreshMiddleware(async (context) =>
            {
 
                context.Response.ContentType = "application/json";
                context.Response.Headers.Append("DOTNET-MODIFIABLE-ASSEMBLIES", "true");
                context.Response.Headers.Append("ASPNETCORE-BROWSER-TOOLS", "true");
                await context.Response.StartAsync();
                await context.Response.WriteAsync("{ }");
            }, NullLogger<BrowserRefreshMiddleware>.Instance);
 
            middleware.Test_SetEnvironment(dotnetModifiableAssemblies: "true", aspnetcoreBrowserTools: "true");
 
            // Act
            await middleware.InvokeAsync(context);
 
            // Assert
            Assert.True(context.Response.Headers.ContainsKey("DOTNET-MODIFIABLE-ASSEMBLIES"));
            Assert.Equal("true", context.Response.Headers["DOTNET-MODIFIABLE-ASSEMBLIES"]);
            Assert.True(context.Response.Headers.ContainsKey("ASPNETCORE-BROWSER-TOOLS"));
            Assert.Equal("true", context.Response.Headers["ASPNETCORE-BROWSER-TOOLS"]);
        }
 
        [Theory]
        [InlineData(500, "text/html")]
        [InlineData(404, "text/html")]
        [InlineData(200, "text/html")]
        public async Task InvokeAsync_AddsScriptToThePage_ForSupportedStatusCodes(int statusCode, string contentType)
        {
            // Act & Assert
            var responseContent = await TestBrowserRefreshMiddleware(statusCode, contentType, "Test Content");
            Assert.Contains("<script src=\"/_framework/aspnetcore-browser-refresh.js\"></script>", responseContent);
        }
 
        [Theory]
        [InlineData(400, "text/html")] // Bad Request
        [InlineData(401, "text/html")] // Unauthorized
        [InlineData(404, "application/json")] // 404 with wrong content type
        [InlineData(200, "application/json")] // 200 with wrong content type
        public async Task InvokeAsync_DoesNotAddScript_ForUnsupportedStatusCodesOrContentTypes(int statusCode, string contentType)
        {
            // Act & Assert
            var responseContent = await TestBrowserRefreshMiddleware(statusCode, contentType, "Test Content", includeHtmlWrapper: false);
            Assert.DoesNotContain("<script src=\"/_framework/aspnetcore-browser-refresh.js\"></script>", responseContent);
        }
 
        private async Task<string> TestBrowserRefreshMiddleware(int statusCode, string contentType, string content, bool includeHtmlWrapper = true)
        {
            // Arrange
            var stream = new MemoryStream();
            var context = new DefaultHttpContext
            {
                Request =
                {
                    Method = "GET",
                    Headers = { ["Accept"] = "text/html" },
                },
                Response =
                {
                    Body = stream
                },
            };
 
            var middleware = new BrowserRefreshMiddleware(async (context) =>
            {
                context.Response.StatusCode = statusCode;
                context.Response.ContentType = contentType;
 
                if (includeHtmlWrapper)
                {
                    await context.Response.WriteAsync("<html>");
                    await context.Response.WriteAsync("<body>");
                    await context.Response.WriteAsync("<h1>");
                    await context.Response.WriteAsync(content);
                    await context.Response.WriteAsync("</h1>");
                    await context.Response.WriteAsync("</body>");
                    await context.Response.WriteAsync("</html>");
                }
                else
                {
                    await context.Response.WriteAsync(content);
                }
            }, NullLogger<BrowserRefreshMiddleware>.Instance);
 
            // Act
            await middleware.InvokeAsync(context);
 
            // Return response content and verify status code
            var responseContent = Encoding.UTF8.GetString(stream.ToArray());
            Assert.Equal(statusCode, context.Response.StatusCode);
            return responseContent;
        }
 
        private class TestHttpResponseFeature : IHttpResponseFeature, IHttpResponseBodyFeature
        {
            private (Func<object, Task> callback, object state)[] _callbacks = [];
            private bool _hasStarted;
 
            public int StatusCode { get; set; }
            public string? ReasonPhrase { get; set; }
            public IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
            public Stream Body { get; set; } = new MemoryStream();
 
            public bool HasStarted => _hasStarted;
 
            public Stream Stream => Body;
 
            public PipeWriter Writer => PipeWriter.Create(Body);
 
            public Task CompleteAsync() => Task.CompletedTask;
 
            public void DisableBuffering() { }
 
            public void OnCompleted(Func<object, Task> callback, object state) => throw new NotImplementedException();
 
            public void OnStarting(Func<object, Task> callback, object state)
            {
                _callbacks = [(callback, state)];
            }
 
            public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) => throw new NotImplementedException();
 
            public async Task StartAsync(CancellationToken cancellationToken = default)
            {
                if(_hasStarted)
                {
                    throw new InvalidOperationException();
                }
 
                foreach (var (callback, state) in _callbacks)
                {
                    await callback(state);
                }
 
                await Stream.FlushAsync();
 
                _hasStarted = true;
            }
        }
    }
}