From 984094d77e25ece693c42bfb325c0a2fba0790fd Mon Sep 17 00:00:00 2001 From: Pekka Tuukkanen Date: Fri, 22 May 2026 16:08:41 +0300 Subject: [PATCH] implement IsBrowser --- .../Features/IHttpBrowserCapabilityFeature.cs | 2 + .../BrowserCapabilitiesFactory.cs | 13 ++- .../Configuration/HttpCapabilitiesBase.cs | 2 + .../HttpBrowserCapabilitiesBase.cs | 2 + .../HttpBrowserCapabilitiesWrapper.cs | 2 + .../BrowserCapabilitiesFactoryTests.cs | 109 ++++++++++++++++++ 6 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Adapters/Features/IHttpBrowserCapabilityFeature.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Adapters/Features/IHttpBrowserCapabilityFeature.cs index 2630c9272..785a6c6f2 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Adapters/Features/IHttpBrowserCapabilityFeature.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Adapters/Features/IHttpBrowserCapabilityFeature.cs @@ -15,6 +15,8 @@ namespace Microsoft.AspNetCore.SystemWebAdapters.Features; public interface IHttpBrowserCapabilityFeature { string? this[string key] { get; } + + bool IsBrowser(string browserName); } #endif diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/BrowserCapabilitiesFactory.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/BrowserCapabilitiesFactory.cs index ac52ca228..4cde0be36 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/BrowserCapabilitiesFactory.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/BrowserCapabilitiesFactory.cs @@ -652,18 +652,19 @@ private bool UcbrowserProcess(string userAgent, ParsedBrowserResult dictionary) private sealed class ParsedBrowserResult : Dictionary, IHttpBrowserCapabilityFeature { + private readonly HashSet _browsers = new(StringComparer.OrdinalIgnoreCase); + public ParsedBrowserResult() : base(StringComparer.OrdinalIgnoreCase) { } - [Conditional("NotNeededYet")] - [Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Retained in case we need the data later")] - public void AddBrowser(string browser) - { - } + public void AddBrowser(string browser) => _browsers.Add(browser); string? IHttpBrowserCapabilityFeature.this[string key] => TryGetValue(key, out var value) ? value : null; + + bool IHttpBrowserCapabilityFeature.IsBrowser(string browserName) + => !string.IsNullOrEmpty(browserName) && _browsers.Contains(browserName); } private readonly struct RegexResult @@ -724,5 +725,7 @@ private sealed class EmptyBrowserFeatures : IHttpBrowserCapabilityFeature public static IHttpBrowserCapabilityFeature Instance { get; } = new EmptyBrowserFeatures(); public string? this[string key] => null; + + public bool IsBrowser(string browserName) => false; } } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/HttpCapabilitiesBase.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/HttpCapabilitiesBase.cs index ad5fd7396..3e7726a29 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/HttpCapabilitiesBase.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/Configuration/HttpCapabilitiesBase.cs @@ -64,6 +64,8 @@ private IHttpBrowserCapabilityFeature Capability public bool IsMobileDevice => GetBoolean("isMobileDevice"); + public bool IsBrowser(string browserName) => Capability.IsBrowser(browserName); + private int GetInt(string key) => int.TryParse(Capability[key], NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : throw new HttpUnhandledException($"Invalid string from browser capabilities '{key}'"); private bool GetBoolean(string key) => bool.TryParse(Capability[key], out var result) && result; diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesBase.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesBase.cs index d62362e74..553f69a62 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesBase.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesBase.cs @@ -19,4 +19,6 @@ public class HttpBrowserCapabilitiesBase public virtual bool Crawler => throw new NotImplementedException(); public virtual bool IsMobileDevice => throw new NotImplementedException(); + + public virtual bool IsBrowser(string browserName) => throw new NotImplementedException(); } diff --git a/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesWrapper.cs b/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesWrapper.cs index 68835d1cd..8955a6ad7 100644 --- a/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesWrapper.cs +++ b/src/Microsoft.AspNetCore.SystemWebAdapters/HttpBrowserCapabilitiesWrapper.cs @@ -27,4 +27,6 @@ public HttpBrowserCapabilitiesWrapper(HttpBrowserCapabilities capabilities) public override bool Crawler => _capabilities.Crawler; public override bool IsMobileDevice => _capabilities.IsMobileDevice; + + public override bool IsBrowser(string browserName) => _capabilities.IsBrowser(browserName); } diff --git a/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/Configuration/BrowserCapabilitiesFactoryTests.cs b/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/Configuration/BrowserCapabilitiesFactoryTests.cs index 615c40f50..18ccfc89d 100644 --- a/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/Configuration/BrowserCapabilitiesFactoryTests.cs +++ b/test/Microsoft.AspNetCore.SystemWebAdapters.Tests/Configuration/BrowserCapabilitiesFactoryTests.cs @@ -225,4 +225,113 @@ public static IEnumerable UserAgentTestData }; } } + + [Theory] + [MemberData(nameof(IsBrowserTestData))] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "Used for tests")] + public void IsBrowserMatchesHierarchy(string userAgent, string[] expectedMatches, string[] expectedNonMatches) + { + // Arrange + var browserFactory = new BrowserCapabilitiesFactory(); + + // Act + var result = browserFactory.Parse(userAgent); + + // Assert + foreach (var name in expectedMatches) + { + Assert.True(result.IsBrowser(name), $"Expected IsBrowser(\"{name}\") to be true for '{userAgent}'"); + } + + foreach (var name in expectedNonMatches) + { + Assert.False(result.IsBrowser(name), $"Expected IsBrowser(\"{name}\") to be false for '{userAgent}'"); + } + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void IsBrowserRejectsEmpty(string? browserName) + { + // Arrange + var browserFactory = new BrowserCapabilitiesFactory(); + + // Act + var result = browserFactory.Parse( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36"); + + // Assert + Assert.False(result.IsBrowser(browserName!)); + } + + [Fact] + public void IsBrowserCaseInsensitive() + { + // Arrange + var browserFactory = new BrowserCapabilitiesFactory(); + + // Act + var result = browserFactory.Parse( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0"); + + // Assert + Assert.True(result.IsBrowser("firefox")); + Assert.True(result.IsBrowser("FIREFOX")); + Assert.True(result.IsBrowser("Firefox")); + } + + public static IEnumerable IsBrowserTestData + { + get + { + // Chrome desktop -> Default, Mozilla, WebKit, Chrome + yield return new object[] + { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36", + new[] { "Default", "Mozilla", "WebKit", "Chrome" }, + new[] { "Firefox", "IE", "Safari", "Opera" }, + }; + + // Firefox desktop -> Default, Mozilla, Firefox + yield return new object[] + { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", + new[] { "Default", "Mozilla", "Firefox" }, + new[] { "Chrome", "WebKit", "IE", "Safari" }, + }; + + // MSIE 10 -> Default, Mozilla, IE + yield return new object[] + { + "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Trident/6.0; MDDCJS)", + new[] { "Default", "Mozilla", "IE" }, + new[] { "Chrome", "Firefox", "WebKit", "Safari" }, + }; + + // iPhone CriOS (no "Chrome/" token in UA, Safari branch wins) -> Default, Mozilla, WebKit, Safari + yield return new object[] + { + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/102.0.5005.87 Mobile/15E148 Safari/604.1", + new[] { "Default", "Mozilla", "WebKit", "Safari" }, + new[] { "Chrome", "Firefox", "IE" }, + }; + + // Opera (handled before Mozilla, no Mozilla node added) -> Default, Opera + yield return new object[] + { + "Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14", + new[] { "Default", "Opera" }, + new[] { "Mozilla", "Chrome", "Firefox", "IE" }, + }; + + // Empty UA via Parse still runs DefaultProcess -> only Default is added + yield return new object[] + { + string.Empty, + new[] { "Default" }, + new[] { "Mozilla", "Chrome", "Firefox", "IE" }, + }; + } + } }