diff --git a/BrowserCache/SessionStorageCacheTests.cs b/BrowserCache/SessionStorageCacheTests.cs new file mode 100644 index 0000000..edce475 --- /dev/null +++ b/BrowserCache/SessionStorageCacheTests.cs @@ -0,0 +1,363 @@ +using FiftyOne.Pipeline.Cloud.SeleniumTests.Examples; +using FiftyOne.Pipeline.Cloud.SeleniumTests.Helpers; +using FiftyOne.Pipeline.Cloud.Tests.Common; +using FiftyOne.Pipeline.Cloud.Tests.Common.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Firefox; +using OpenQA.Selenium.Remote; +using OpenQA.Selenium.Support.UI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace FiftyOne.Pipeline.Cloud.SeleniumTests.BrowserCache +{ + /// + /// Verify session storage cache behaviour: after navigating to a + /// second page in the same tab, the results are reused and no new + /// pipeline requests are made. + /// + [TestClass, TestCategory("Contract")] + public class SessionStorageCacheTests + { + private const int CompletionTimeoutSeconds = 60; + + private static IExampleApp _app; + private static CancellationTokenSource _appTokenSource; + + private WebDriver driver; + private string ClientServerUrl; + private System.Net.HttpListener clientServer; + private HttpServer clientHttpServer; + private CancellationTokenSource clientServerTokenSource; + + /// + /// Starts the example app once for the whole class. + /// + [ClassInitialize] + public static void ClassInit(TestContext context) + { + string rootUrl; + string resourceKey; + try + { + rootUrl = TestConfig.Instance().RootUrl; + resourceKey = TestConfig.Instance().PaidResourceKey; + } + catch (InvalidOperationException ex) + { + Assert.Inconclusive(ex.Message); + return; + } + + if (!ExampleApps.TryCreate(out _app, out var skipReason)) + { + Assert.Inconclusive(skipReason); + return; + } + + _appTokenSource = new CancellationTokenSource(); + var options = new ExampleAppOptions( + TestHelpers.GetRandomUnusedPort(), + new Uri(rootUrl), + resourceKey, + new Dictionary()); + _app.StartAsync(options, _appTokenSource.Token) + .GetAwaiter().GetResult(); + } + + /// + /// Stops the example app. + /// + [ClassCleanup] + public static void ClassCleanupApp() + { + _appTokenSource?.Cancel(); + _app?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _app = null; + } + + /// + /// A page that renders what fod.complete delivers, in the way a + /// customer's page would. The test reads the rendered cells rather than + /// script variables, so it exercises the callback actually handing over + /// usable detection results instead of merely inspecting the include's + /// internal state. + /// + private static string BuildPage(bool enableCookies) + { + var flag = enableCookies ? "true" : "false"; + return @" + + + + Session Storage Cache Test + + + + +

Session Storage Cache Test

+ + + + + +
Device Id:
Hardware Name:
Platform Name:
Device Type:
+ + +"; + } + + /// Detection results as rendered onto the page. + private sealed record RenderedResults( + string DeviceId, string HardwareName, string PlatformName, string DeviceType); + + /// + /// Session storage cache behaviour in Chrome. + /// + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void SessionStorageCache_Chrome(bool enableCookies) + { + var options = new ChromeOptions(); + options.AcceptInsecureCertificates = true; + options.AddArgument("--headless"); + + if (ExternalSeleniumHelper.IsExternalSelenium(out var seleniumUrl)) + { + ExternalSeleniumHelper.AddExternalSeleniumArguments(options); + driver = new RemoteWebDriver(new Uri(seleniumUrl), options); + } + else + { + driver = new ChromeDriver(options); + } + + RunTest(driver, enableCookies); + } + + /// + /// Session storage cache behaviour in Firefox. + /// + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void SessionStorageCache_FireFox(bool enableCookies) + { + var options = new FirefoxOptions(); + options.AcceptInsecureCertificates = true; + options.AddArgument("--headless"); + + if (ExternalSeleniumHelper.IsExternalSelenium(out var seleniumUrl)) + { + ExternalSeleniumHelper.AddExternalSeleniumArguments(options); + driver = new RemoteWebDriver(new Uri(seleniumUrl), options); + } + else + { + driver = new FirefoxDriver(options); + } + + RunTest(driver, enableCookies); + } + + private void RunTest(WebDriver driver, bool enableCookies) + { + ClientServerUrl = $"http://localhost:{TestHelpers.GetRandomUnusedPort()}/"; + clientServerTokenSource = new CancellationTokenSource(); + var serverListener = TestHelpers.ExampleProxyListener( + ClientServerUrl, + _app.BaseUrl.ToString(), + BuildPage(enableCookies), + ExampleApps.JsonEndpointPath, + clientServerTokenSource.Token); + clientServer = serverListener.Listener; + clientHttpServer = serverListener.Server; + + IJavaScriptExecutor js = driver; + + driver.Navigate().GoToUrl(ClientServerUrl + "page1"); + var page1 = WaitForRenderedResults(driver, "first page"); + var sessionIdPage1 = (string)js.ExecuteScript("return fod.sessionId"); + var keysPage1 = GetSessionStorageKeys(js); + + Assert.IsTrue(CountJsonPosts() >= 1, + "the first page must call the json endpoint at least once"); + Assert.IsFalse(string.IsNullOrEmpty(page1.DeviceId), + "the first page must render a device id from fod.complete"); + + // A second page in the same tab, then a reload of it. Both reuse the + // cached results, so neither may call the json endpoint again and + // both must render the values the first page resolved. + clientHttpServer.ResetRequests(); + driver.Navigate().GoToUrl(ClientServerUrl + "page2"); + var page2 = WaitForRenderedResults(driver, "second page"); + var sessionIdPage2 = (string)js.ExecuteScript("return fod.sessionId"); + var keysPage2 = GetSessionStorageKeys(js); + var postsPage2 = CountJsonPosts(); + + clientHttpServer.ResetRequests(); + driver.Navigate().Refresh(); + var reloaded = WaitForRenderedResults(driver, "reloaded second page"); + var keysReloaded = GetSessionStorageKeys(js); + var postsReloaded = CountJsonPosts(); + + // Comparing the two ids only says anything if there are ids to + // compare. An integration that leaves fod.sessionId empty makes + // AreNotEqual fail with "Expected any value except:<>", which reads + // as a caching problem when it is really a missing value, so say so + // and skip rather than report a failure this test cannot diagnose. + if (string.IsNullOrEmpty(sessionIdPage1) + || string.IsNullOrEmpty(sessionIdPage2)) + { + Assert.Inconclusive( + $"The '{ExampleApps.SelectedLang}' example serves an include " + + "with no session id, so whether it was re-fetched cannot be " + + $"determined (page 1 '{sessionIdPage1}', page 2 '{sessionIdPage2}')."); + return; + } + + Assert.AreNotEqual(sessionIdPage1, sessionIdPage2, + "the include must be fetched fresh on the second page, " + + "not served from the browser cache"); + + AssertSameResults(page1, page2, "second page"); + AssertSameResults(page1, reloaded, "reloaded second page"); + + Assert.AreEqual(0, postsPage2, + "no json refresh call is expected on the second page"); + Assert.AreEqual(0, postsReloaded, + "no json refresh call is expected when the page is reloaded"); + + if (enableCookies) + { + var cookies = (string)js.ExecuteScript("return document.cookie"); + StringAssert.Contains(cookies, "51D_", + "the evidence cookies must be present"); + } + else + { + CollectionAssert.AreEqual(keysPage1, keysPage2, + "session storage keys must not change between page views: " + + $"[{string.Join(", ", keysPage1)}] -> [{string.Join(", ", keysPage2)}]"); + CollectionAssert.AreEqual(keysPage1, keysReloaded, + "session storage keys must not change when the page is reloaded: " + + $"[{string.Join(", ", keysPage1)}] -> [{string.Join(", ", keysReloaded)}]"); + } + } + + /// + /// Every rendered value must survive, not just the device id: a cache + /// that returned a different but still populated result would otherwise + /// pass. + /// + private static void AssertSameResults( + RenderedResults expected, RenderedResults actual, string phase) + { + Assert.AreEqual(expected.DeviceId, actual.DeviceId, + $"the device id rendered on the {phase} must come from the cached results"); + Assert.AreEqual(expected.HardwareName, actual.HardwareName, + $"the hardware name rendered on the {phase} must come from the cached results"); + Assert.AreEqual(expected.PlatformName, actual.PlatformName, + $"the platform name rendered on the {phase} must come from the cached results"); + Assert.AreEqual(expected.DeviceType, actual.DeviceType, + $"the device type rendered on the {phase} must come from the cached results"); + } + + private int CountJsonPosts() + { + var expected = $"POST {ExampleApps.JsonEndpointPath}"; + return clientHttpServer.RequestLog + .Count(r => string.Equals(r, expected, StringComparison.OrdinalIgnoreCase)); + } + + private static List GetSessionStorageKeys(IJavaScriptExecutor js) + { + var raw = (System.Collections.ObjectModel.ReadOnlyCollection) + js.ExecuteScript("return Object.keys(sessionStorage)"); + return raw.Select(o => (string)o) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Waits for the page's own callback to render its results, then reads + /// them back out of the DOM. Reading the rendered cells rather than the + /// include's variables is what makes this a test of fod.complete handing + /// over usable data, rather than of the script's internal state. + /// + private static RenderedResults WaitForRenderedResults( + WebDriver driver, string phase) + { + IJavaScriptExecutor js = driver; + try + { + new WebDriverWait(driver, TimeSpan.FromSeconds(CompletionTimeoutSeconds)) + .Until(d => "complete".Equals( + d.FindElement(By.Id("results")).GetAttribute("data-state"))); + } + catch (WebDriverTimeoutException) + { + var state = js.ExecuteScript( + "var r = document.getElementById('results');" + + "return r ? r.getAttribute('data-state') : 'no-element';"); + var fodDefined = js.ExecuteScript("return typeof fod !== 'undefined'"); + var fodErrors = js.ExecuteScript( + "return typeof fod === 'undefined' || !fod.errors ? '' : JSON.stringify(fod.errors)"); + var storage = js.ExecuteScript( + "return Object.keys(sessionStorage).join(',')"); + Assert.Fail( + $"Timed out waiting for fod.complete to render results during {phase}. " + + $"readyState={js.ExecuteScript("return document.readyState")}, " + + $"resultsState={state}, fodDefined={fodDefined}, " + + $"fodErrors={fodErrors}, sessionStorage=[{storage}]"); + } + + return new RenderedResults( + driver.FindElement(By.Id("deviceid")).Text.Trim(), + driver.FindElement(By.Id("hardwarename")).Text.Trim(), + driver.FindElement(By.Id("platformname")).Text.Trim(), + driver.FindElement(By.Id("devicetype")).Text.Trim()); + } + + /// + /// Cleans up after the test. + /// + [TestCleanup] + public void Cleanup() + { + driver?.Quit(); + driver = null; + clientServerTokenSource?.Cancel(); + clientServer?.Stop(); + clientServer?.Close(); + } + } +} diff --git a/Examples/ExampleApps.cs b/Examples/ExampleApps.cs index 8d5075b..2b268af 100644 --- a/Examples/ExampleApps.cs +++ b/Examples/ExampleApps.cs @@ -19,6 +19,17 @@ public static class ExampleApps public static string SelectedLang => Environment.GetEnvironmentVariable(ExampleLangVar) ?? "dotnet"; + /// + /// Path the selected language's client-side script posts refreshed + /// evidence to. Read from the descriptor rather than the running app, + /// so it is available for the CI case too, where EXAMPLE_URL points at + /// an already-running example and no descriptor is used to launch it. + /// + public static string JsonEndpointPath => + Descriptors.TryGetValue(SelectedLang, out var descriptor) + ? descriptor.JsonEndpointPath + : "/51dpipeline/json"; + /// /// Element the selected language's page renders its client-side results /// into. Read from the descriptor so it is available for the CI case @@ -98,6 +109,7 @@ public static bool TryCreate(out IExampleApp app, out string skipReason) BuildArgs: new[] { "-pl", "web/getting-started.cloud", "-am", "package", "-DskipTests" }, RunArtifactGlob: "web/getting-started.cloud/target/*-jar-with-dependencies.jar", BuildTimeoutSeconds: 600, + JsonEndpointPath: "/51Degrees.core.json", // the java page renders the client-side results into its // Apple detection section rather than the shared container ClientResultsElementId: "apple-detection"), @@ -123,7 +135,8 @@ public static bool TryCreate(out IExampleApp app, out string skipReason) BuildCommand: "npm", // package.json lives one level up from examples/cloud/gettingstarted-web BuildArgs: new[] { "install", "--prefix", "../../.." }, - BuildTimeoutSeconds: 300), + BuildTimeoutSeconds: 300, + JsonEndpointPath: "/json"), ["python"] = new ExampleDescriptor( Lang: "python", WorkingDir: Path.Combine( @@ -147,7 +160,8 @@ public static bool TryCreate(out IExampleApp app, out string skipReason) }, BuildCommand: "bash", BuildArgs: new[] { "-c", "python3 -m venv .venv && .venv/bin/pip install -e ." }, - BuildTimeoutSeconds: 600), + BuildTimeoutSeconds: 600, + JsonEndpointPath: "/json"), ["php"] = new ExampleDescriptor( Lang: "php", WorkingDir: Path.Combine( @@ -171,7 +185,8 @@ public static bool TryCreate(out IExampleApp app, out string skipReason) }, BuildCommand: "composer", BuildArgs: new[] { "install", "--working-dir=.." }, - BuildTimeoutSeconds: 600), + BuildTimeoutSeconds: 600, + JsonEndpointPath: "/json"), ["rust"] = new ExampleDescriptor( Lang: "rust", // the examples form their own workspace under examples/; diff --git a/Examples/ExampleDescriptor.cs b/Examples/ExampleDescriptor.cs index 6f91352..f876a1d 100644 --- a/Examples/ExampleDescriptor.cs +++ b/Examples/ExampleDescriptor.cs @@ -18,6 +18,13 @@ namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Examples /// Arguments for the build executable. /// Optional glob, relative to WorkingDir, resolved after the build and appended as the final run argument. /// Seconds to wait for the build to finish. + /// + /// Path the client-side script posts refreshed evidence to. Each web + /// integration picks its own, so a test that proxies or counts those + /// requests has to ask the descriptor rather than assume one: dotnet and + /// rust use the default, java uses /51Degrees.core.json, and node, python + /// and php use /json. + /// /// /// Id of the element the example's page asks the shared examples helper to /// render the client-side results into. Every example passes its own @@ -36,5 +43,6 @@ public sealed record ExampleDescriptor( IReadOnlyList BuildArgs = null, string RunArtifactGlob = null, int BuildTimeoutSeconds = 0, + string JsonEndpointPath = "/51dpipeline/json", string ClientResultsElementId = "content"); } diff --git a/TestsCommon/Helpers/HttpServer.cs b/TestsCommon/Helpers/HttpServer.cs index b4a5f2e..23aae4a 100644 --- a/TestsCommon/Helpers/HttpServer.cs +++ b/TestsCommon/Helpers/HttpServer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net; @@ -55,6 +56,27 @@ public class HttpServer /// public string ProxyAllTo { get; private set; } + /// + /// Path prefixes proxied to another server, e.g. the example app + /// under test. + /// + public IReadOnlyDictionary ProxyRoutes { get; set; } + + /// + /// Response headers forced onto responses proxied via + /// . + /// + public IReadOnlyDictionary ProxiedHeaderOverrides { get; set; } + + /// + /// Method and path of each request handled since the last reset. + /// Written by the listen loop and read from the test thread while the + /// server is live, so it must tolerate concurrent access: enumerating + /// a takes a snapshot rather than + /// throwing when a request arrives mid-read. + /// + public ConcurrentQueue RequestLog { get; } = new ConcurrentQueue(); + private static readonly HttpClient _httpClient = new HttpClient(); /// @@ -66,12 +88,27 @@ public HttpServer(HttpListener listener) Listener = listener; } + /// + /// A key ending in '/' matches everything + /// beneath it; any other key matches that one path exactly. The + /// distinction matters because a prefix match on '/51Degrees.core.js' + /// also captures '/51Degrees.core.json', which is a separate endpoint + /// in the java web integration. + /// + private static bool RouteMatches(string routeKey, string path) + { + return routeKey.EndsWith("/", StringComparison.Ordinal) + ? path.StartsWith(routeKey, StringComparison.OrdinalIgnoreCase) + : string.Equals(path, routeKey, StringComparison.OrdinalIgnoreCase); + } + /// /// Reset the counter to zero. /// public void ResetRequests() { RequestCount = 0; + RequestLog.Clear(); } /// @@ -142,12 +179,35 @@ public async Task HandleIncomingConnections(CancellationToken token) RequestCount = RequestCount + 1; + if (req.Url != null) + { + RequestLog.Enqueue($"{req.HttpMethod} {req.Url.AbsolutePath}"); + } + + string proxyRouteTarget = null; + if (ProxyRoutes != null && req.Url != null) + { + foreach (var route in ProxyRoutes) + { + if (RouteMatches(route.Key, req.Url.AbsolutePath)) + { + proxyRouteTarget = route.Value; + break; + } + } + } + try { if (ProxyAllTo != null) { await ProxyTo(ProxyAllTo, req, resp, addExtraHeaders: true); } + else if (proxyRouteTarget != null) + { + await ProxyTo(proxyRouteTarget, req, resp, + addExtraHeaders: false, ProxiedHeaderOverrides); + } else if (CloudUrl != null && req.Url != null && req.Url.AbsolutePath.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) @@ -181,7 +241,8 @@ private async Task ProxyTo( string baseUrl, HttpListenerRequest req, HttpListenerResponse resp, - bool addExtraHeaders) + bool addExtraHeaders, + IReadOnlyDictionary headerOverrides = null) { var targetUri = new Uri(new Uri(baseUrl), req.Url.PathAndQuery.TrimStart('/')); using var outgoing = new HttpRequestMessage(new HttpMethod(req.HttpMethod), targetUri); @@ -230,6 +291,14 @@ private async Task ProxyTo( CopyResponseHeaders(response.Headers, resp); CopyResponseHeaders(response.Content.Headers, resp); + if (headerOverrides != null) + { + foreach (var kv in headerOverrides) + { + resp.Headers[kv.Key] = kv.Value; + } + } + if (addExtraHeaders && ExtraResponseHeaders != null) { foreach (var kv in ExtraResponseHeaders) diff --git a/TestsCommon/Helpers/TestHelpers.cs b/TestsCommon/Helpers/TestHelpers.cs index 43a3be5..9ebe0e9 100644 --- a/TestsCommon/Helpers/TestHelpers.cs +++ b/TestsCommon/Helpers/TestHelpers.cs @@ -99,6 +99,42 @@ public static ServerListener ReverseProxyListener( return new ServerListener(listener, server); } + /// + /// Creates an HttpListener that serves + /// for page paths and proxies the 51Degrees script and pipeline + /// endpoints to without caching. + /// is the path the client-side + /// script posts refreshed evidence to, which differs per web + /// integration, so the caller supplies the one its example uses. + /// + public static ServerListener ExampleProxyListener( + string clientUrl, + string exampleUrl, + string pageData, + string jsonEndpointPath, + CancellationToken token) + { + var listener = new HttpListener(); + listener.Prefixes.Add(clientUrl); + listener.Start(); + + var server = new HttpServer(listener, pageData) + { + ProxyRoutes = new Dictionary + { + ["/51Degrees.core.js"] = exampleUrl, + [jsonEndpointPath] = exampleUrl, + }, + ProxiedHeaderOverrides = new Dictionary + { + ["Cache-Control"] = "no-store", + }, + }; + Task listenTask = server.HandleIncomingConnections(token); + listenTask.GetAwaiter(); + return new ServerListener(listener, server); + } + /// /// Start a new TcpListener with the port as 0 so that the OS assigns an /// available port. Record this then close the listener. This ensures