From 4fd1f13065645d8e17549fa5e1e2fadac62dddb7 Mon Sep 17 00:00:00 2001 From: YaroslavVlasenko Date: Thu, 30 Jul 2026 19:03:52 +0300 Subject: [PATCH 1/6] TEST: Add Selenium tests for session storage cache behavior in Chrome and Firefox, including proxy setup and enhancements to test utilities --- BrowserCache/SessionStorageCacheTests.cs | 279 +++++++++++++++++++++++ TestsCommon/Helpers/HttpServer.cs | 53 ++++- TestsCommon/Helpers/TestHelpers.cs | 32 +++ 3 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 BrowserCache/SessionStorageCacheTests.cs diff --git a/BrowserCache/SessionStorageCacheTests.cs b/BrowserCache/SessionStorageCacheTests.cs new file mode 100644 index 0000000..7bdb958 --- /dev/null +++ b/BrowserCache/SessionStorageCacheTests.cs @@ -0,0 +1,279 @@ +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 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 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; + } + + private static string BuildPage(bool enableCookies) + { + var flag = enableCookies ? "true" : "false"; + return @" + + + + Session Storage Cache Test + + + + +

Session Storage Cache Test

+ + +"; + } + + /// + /// 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), + clientServerTokenSource.Token); + clientServer = serverListener.Listener; + clientHttpServer = serverListener.Server; + + IJavaScriptExecutor js = driver; + + driver.Navigate().GoToUrl(ClientServerUrl + "page1"); + WaitForFodDone(driver, js, "first page"); + + var devicePage1 = (string)js.ExecuteScript("return window.fodDevice"); + var sessionIdPage1 = (string)js.ExecuteScript("return fod.sessionId"); + var keysPage1 = GetSessionStorageKeys(js); + var postsPage1 = CountJsonPosts(); + Assert.IsTrue(postsPage1 >= 1, + "the first page must call the json endpoint at least once"); + + clientHttpServer.ResetRequests(); + driver.Navigate().GoToUrl(ClientServerUrl + "page2"); + WaitForFodDone(driver, js, "second page"); + + var devicePage2 = (string)js.ExecuteScript("return window.fodDevice"); + var sessionIdPage2 = (string)js.ExecuteScript("return fod.sessionId"); + var keysPage2 = GetSessionStorageKeys(js); + var postsPage2 = CountJsonPosts(); + + Assert.AreNotEqual(sessionIdPage1, sessionIdPage2, + "the include must be fetched fresh on the second page, " + + "not served from the browser cache"); + Assert.IsFalse( + string.IsNullOrEmpty(devicePage2) || devicePage2 == "null", + "device data must be available on the second page"); + if (enableCookies) + { + // With cookies the values survive in the cookies themselves, + // so only check that path is in use. Session storage keys and + // repeat calls are not stable here: scripts without saved + // values run again on every page. + 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)}]"); + Assert.AreEqual(0, postsPage2, + "no json refresh call is expected on the second page"); + } + } + + private int CountJsonPosts() + { + return clientHttpServer.RequestLog + .Count(r => r == "POST /51dpipeline/json"); + } + + 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(); + } + + private static void WaitForFodDone( + WebDriver driver, IJavaScriptExecutor js, string phase) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60); + while (true) + { + if (true.Equals(js.ExecuteScript("return window.fodDone === true"))) + { + return; + } + if (DateTime.UtcNow >= deadline) + { + var readyState = js.ExecuteScript("return document.readyState"); + var fodError = js.ExecuteScript("return window.fodError || ''"); + 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 completion during {phase}. " + + $"readyState={readyState}, fodDefined={fodDefined}, " + + $"fodError={fodError}, fodErrors={fodErrors}, " + + $"sessionStorage=[{storage}]"); + } + Thread.Sleep(1000); + } + } + + /// + /// Cleans up after the test. + /// + [TestCleanup] + public void Cleanup() + { + driver?.Quit(); + driver = null; + clientServerTokenSource?.Cancel(); + clientServer?.Stop(); + clientServer?.Close(); + } + } +} diff --git a/TestsCommon/Helpers/HttpServer.cs b/TestsCommon/Helpers/HttpServer.cs index b4a5f2e..d54ae83 100644 --- a/TestsCommon/Helpers/HttpServer.cs +++ b/TestsCommon/Helpers/HttpServer.cs @@ -55,6 +55,23 @@ 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. + /// + public List RequestLog { get; } = new List(); + private static readonly HttpClient _httpClient = new HttpClient(); /// @@ -72,6 +89,7 @@ public HttpServer(HttpListener listener) public void ResetRequests() { RequestCount = 0; + RequestLog.Clear(); } /// @@ -142,12 +160,36 @@ public async Task HandleIncomingConnections(CancellationToken token) RequestCount = RequestCount + 1; + if (req.Url != null) + { + RequestLog.Add($"{req.HttpMethod} {req.Url.AbsolutePath}"); + } + + string proxyRouteTarget = null; + if (ProxyRoutes != null && req.Url != null) + { + foreach (var route in ProxyRoutes) + { + if (req.Url.AbsolutePath.StartsWith( + route.Key, StringComparison.OrdinalIgnoreCase)) + { + 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 +223,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 +273,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..8d4000b 100644 --- a/TestsCommon/Helpers/TestHelpers.cs +++ b/TestsCommon/Helpers/TestHelpers.cs @@ -99,6 +99,38 @@ 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. + /// + public static ServerListener ExampleProxyListener( + string clientUrl, + string exampleUrl, + string pageData, + 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, + ["/51dpipeline/"] = 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 From b43232b7b1adb63662e0ef1a48a9fe4c072ddb60 Mon Sep 17 00:00:00 2001 From: Eugene Dzhurinsky Date: Wed, 5 Aug 2026 13:26:48 +0200 Subject: [PATCH 2/6] TEST: Make HttpServer.RequestLog safe for concurrent access The listen loop appends to RequestLog while the test thread enumerates it (CountJsonPosts) and clears it (ResetRequests). A browser-initiated request landing mid-read - a favicon fetch after load, or a late pipeline call - would throw "collection was modified" or return a torn count, failing the test for reasons unrelated to the behaviour under test. ConcurrentQueue enumerates a snapshot instead. --- TestsCommon/Helpers/HttpServer.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/TestsCommon/Helpers/HttpServer.cs b/TestsCommon/Helpers/HttpServer.cs index d54ae83..d28f53a 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; @@ -69,8 +70,12 @@ public class HttpServer /// /// 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 List RequestLog { get; } = new List(); + public ConcurrentQueue RequestLog { get; } = new ConcurrentQueue(); private static readonly HttpClient _httpClient = new HttpClient(); @@ -162,7 +167,7 @@ public async Task HandleIncomingConnections(CancellationToken token) if (req.Url != null) { - RequestLog.Add($"{req.HttpMethod} {req.Url.AbsolutePath}"); + RequestLog.Enqueue($"{req.HttpMethod} {req.Url.AbsolutePath}"); } string proxyRouteTarget = null; From 1e3cdea56ba8e08459d1f81d5f527e63ecb51c69 Mon Sep 17 00:00:00 2001 From: Eugene Dzhurinsky Date: Thu, 6 Aug 2026 13:47:18 +0200 Subject: [PATCH 3/6] TEST: Take the json endpoint path from the example descriptor The path the client-side script posts refreshed evidence to is not the same in every web integration, so hardcoding /51dpipeline/json meant the proxy never forwarded that request and the counter never saw it for four of the six languages. Measured in a browser: dotnet and rust post to /51dpipeline/json, java to /51Degrees.core.json, and node, python and php to /json. Each descriptor now declares its own, defaulting to the dotnet path, and both the proxy route and CountJsonPosts read it from there. The lookup goes through the descriptor rather than the running app so it also works for the CI case, where EXAMPLE_URL points at an already-running example. With this, the node and python legs pass in both browsers, cookies on and off. dotnet, java and php still fail the non-cookie legs on the session storage key assertion, which is the released-template gap this test is for. --- BrowserCache/SessionStorageCacheTests.cs | 4 +++- Examples/ExampleApps.cs | 23 +++++++++++++++++++---- Examples/ExampleDescriptor.cs | 10 +++++++++- TestsCommon/Helpers/TestHelpers.cs | 6 +++++- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/BrowserCache/SessionStorageCacheTests.cs b/BrowserCache/SessionStorageCacheTests.cs index 7bdb958..e74bd18 100644 --- a/BrowserCache/SessionStorageCacheTests.cs +++ b/BrowserCache/SessionStorageCacheTests.cs @@ -168,6 +168,7 @@ private void RunTest(WebDriver driver, bool enableCookies) ClientServerUrl, _app.BaseUrl.ToString(), BuildPage(enableCookies), + ExampleApps.JsonEndpointPath, clientServerTokenSource.Token); clientServer = serverListener.Listener; clientHttpServer = serverListener.Server; @@ -221,8 +222,9 @@ private void RunTest(WebDriver driver, bool enableCookies) private int CountJsonPosts() { + var expected = $"POST {ExampleApps.JsonEndpointPath}"; return clientHttpServer.RequestLog - .Count(r => r == "POST /51dpipeline/json"); + .Count(r => string.Equals(r, expected, StringComparison.OrdinalIgnoreCase)); } private static List GetSessionStorageKeys(IJavaScriptExecutor js) diff --git a/Examples/ExampleApps.cs b/Examples/ExampleApps.cs index 07b22c8..2c1f522 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"; + /// /// Attempts to create the example provider for the current environment. /// Returns false when no descriptor is registered for the selected language, @@ -87,7 +98,8 @@ public static bool TryCreate(out IExampleApp app, out string skipReason) BuildCommand: "mvn", BuildArgs: new[] { "-pl", "web/getting-started.cloud", "-am", "package", "-DskipTests" }, RunArtifactGlob: "web/getting-started.cloud/target/*-jar-with-dependencies.jar", - BuildTimeoutSeconds: 600), + BuildTimeoutSeconds: 600, + JsonEndpointPath: "/51Degrees.core.json"), ["node"] = new ExampleDescriptor( Lang: "node", WorkingDir: Path.Combine( @@ -110,7 +122,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( @@ -134,7 +147,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( @@ -158,7 +172,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 c82382f..46f2147 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. + /// public sealed record ExampleDescriptor( string Lang, string WorkingDir, @@ -29,5 +36,6 @@ public sealed record ExampleDescriptor( string BuildCommand = null, IReadOnlyList BuildArgs = null, string RunArtifactGlob = null, - int BuildTimeoutSeconds = 0); + int BuildTimeoutSeconds = 0, + string JsonEndpointPath = "/51dpipeline/json"); } diff --git a/TestsCommon/Helpers/TestHelpers.cs b/TestsCommon/Helpers/TestHelpers.cs index 8d4000b..9ebe0e9 100644 --- a/TestsCommon/Helpers/TestHelpers.cs +++ b/TestsCommon/Helpers/TestHelpers.cs @@ -103,11 +103,15 @@ public static ServerListener ReverseProxyListener( /// 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(); @@ -119,7 +123,7 @@ public static ServerListener ExampleProxyListener( ProxyRoutes = new Dictionary { ["/51Degrees.core.js"] = exampleUrl, - ["/51dpipeline/"] = exampleUrl, + [jsonEndpointPath] = exampleUrl, }, ProxiedHeaderOverrides = new Dictionary { From ac66fec9a27595e22b870d1796e64bd5c59bd601 Mon Sep 17 00:00:00 2001 From: Eugene Dzhurinsky Date: Thu, 6 Aug 2026 13:47:29 +0200 Subject: [PATCH 4/6] TEST: Match proxy routes on a path boundary, not a bare prefix StartsWith meant the /51Degrees.core.js route also captured /51Degrees.core.json, which is where the java integration posts its refreshed evidence. Java's callback was being proxied through the script route by accident, so it worked but was attributed to the wrong route and could never be counted. A route key ending in '/' still matches everything beneath it; any other key now has to match the path exactly. --- TestsCommon/Helpers/HttpServer.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/TestsCommon/Helpers/HttpServer.cs b/TestsCommon/Helpers/HttpServer.cs index d28f53a..23aae4a 100644 --- a/TestsCommon/Helpers/HttpServer.cs +++ b/TestsCommon/Helpers/HttpServer.cs @@ -88,6 +88,20 @@ 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. /// @@ -175,8 +189,7 @@ public async Task HandleIncomingConnections(CancellationToken token) { foreach (var route in ProxyRoutes) { - if (req.Url.AbsolutePath.StartsWith( - route.Key, StringComparison.OrdinalIgnoreCase)) + if (RouteMatches(route.Key, req.Url.AbsolutePath)) { proxyRouteTarget = route.Value; break; From 6ca6fe7f0ba0949ec6d734413d1c0ea053a6c773 Mon Sep 17 00:00:00 2001 From: Eugene Dzhurinsky Date: Thu, 6 Aug 2026 14:48:21 +0200 Subject: [PATCH 5/6] TEST: Skip when the include carries no session id AreNotEqual on two session ids only means something when there are ids to compare. An integration that leaves fod.sessionId empty failed with "Expected any value except:<>. Actual:<>", which reads as the include being served from the browser cache when it is really a value the example never supplied, and the test has no way to tell those apart. Both ids are now checked for content first, and an empty one reports Inconclusive naming the language and the two values. That turns the rust example, whose include carries an empty session id, from four confusing failures into four explicit skips, and it gates on the capability rather than on a hardcoded list of languages. --- BrowserCache/SessionStorageCacheTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/BrowserCache/SessionStorageCacheTests.cs b/BrowserCache/SessionStorageCacheTests.cs index e74bd18..5bc9681 100644 --- a/BrowserCache/SessionStorageCacheTests.cs +++ b/BrowserCache/SessionStorageCacheTests.cs @@ -194,6 +194,21 @@ private void RunTest(WebDriver driver, bool enableCookies) var keysPage2 = GetSessionStorageKeys(js); var postsPage2 = 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"); From f29625905b934ae11ef230daaa47d008fffc4909 Mon Sep 17 00:00:00 2001 From: Eugene Dzhurinsky Date: Thu, 6 Aug 2026 15:09:52 +0200 Subject: [PATCH 6/6] TEST: Assert the rendered results, a reload, and no refetch on either leg The test read fod.sessionId and window.fodDevice out of the include's own variables, which checks the script's internal state rather than whether fod.complete hands a page usable results. The page now renders the device id, hardware name, platform name and device type into a table the way a customer's page would, and the test reads those cells back. It also captured the first page's device data and never compared it, so a cache returning different but still populated results would have passed. Every rendered value is now compared against the first page. Adds a reload of the second page, so a refresh is covered as well as a navigation, and asserts no json refresh call on the cookie leg too. The comment claiming repeat calls were not stable with cookies was wrong: measured against the pre-fix package, the cookie leg makes a second call and fails, and against the fixed package it makes none and passes. --- BrowserCache/SessionStorageCacheTests.cs | 161 ++++++++++++++++------- 1 file changed, 114 insertions(+), 47 deletions(-) diff --git a/BrowserCache/SessionStorageCacheTests.cs b/BrowserCache/SessionStorageCacheTests.cs index 5bc9681..edce475 100644 --- a/BrowserCache/SessionStorageCacheTests.cs +++ b/BrowserCache/SessionStorageCacheTests.cs @@ -7,6 +7,7 @@ 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; @@ -23,6 +24,8 @@ namespace FiftyOne.Pipeline.Cloud.SeleniumTests.BrowserCache [TestClass, TestCategory("Contract")] public class SessionStorageCacheTests { + private const int CompletionTimeoutSeconds = 60; + private static IExampleApp _app; private static CancellationTokenSource _appTokenSource; @@ -78,6 +81,13 @@ public static void ClassCleanupApp() _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"; @@ -88,28 +98,46 @@ private static string BuildPage(bool enableCookies) 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. /// @@ -176,24 +204,31 @@ private void RunTest(WebDriver driver, bool enableCookies) IJavaScriptExecutor js = driver; driver.Navigate().GoToUrl(ClientServerUrl + "page1"); - WaitForFodDone(driver, js, "first page"); - - var devicePage1 = (string)js.ExecuteScript("return window.fodDevice"); + var page1 = WaitForRenderedResults(driver, "first page"); var sessionIdPage1 = (string)js.ExecuteScript("return fod.sessionId"); var keysPage1 = GetSessionStorageKeys(js); - var postsPage1 = CountJsonPosts(); - Assert.IsTrue(postsPage1 >= 1, + + 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"); - WaitForFodDone(driver, js, "second page"); - - var devicePage2 = (string)js.ExecuteScript("return window.fodDevice"); + 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 @@ -212,15 +247,17 @@ private void RunTest(WebDriver driver, bool enableCookies) Assert.AreNotEqual(sessionIdPage1, sessionIdPage2, "the include must be fetched fresh on the second page, " + "not served from the browser cache"); - Assert.IsFalse( - string.IsNullOrEmpty(devicePage2) || devicePage2 == "null", - "device data must be available on the second page"); + + 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) { - // With cookies the values survive in the cookies themselves, - // so only check that path is in use. Session storage keys and - // repeat calls are not stable here: scripts without saved - // values run again on every page. var cookies = (string)js.ExecuteScript("return document.cookie"); StringAssert.Contains(cookies, "51D_", "the evidence cookies must be present"); @@ -230,11 +267,30 @@ private void RunTest(WebDriver driver, bool enableCookies) CollectionAssert.AreEqual(keysPage1, keysPage2, "session storage keys must not change between page views: " + $"[{string.Join(", ", keysPage1)}] -> [{string.Join(", ", keysPage2)}]"); - Assert.AreEqual(0, postsPage2, - "no json refresh call is expected on the second page"); + 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}"; @@ -251,33 +307,44 @@ private static List GetSessionStorageKeys(IJavaScriptExecutor js) .ToList(); } - private static void WaitForFodDone( - WebDriver driver, IJavaScriptExecutor js, string phase) + /// + /// 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) { - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60); - while (true) + IJavaScriptExecutor js = driver; + try { - if (true.Equals(js.ExecuteScript("return window.fodDone === true"))) - { - return; - } - if (DateTime.UtcNow >= deadline) - { - var readyState = js.ExecuteScript("return document.readyState"); - var fodError = js.ExecuteScript("return window.fodError || ''"); - 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 completion during {phase}. " + - $"readyState={readyState}, fodDefined={fodDefined}, " + - $"fodError={fodError}, fodErrors={fodErrors}, " + - $"sessionStorage=[{storage}]"); - } - Thread.Sleep(1000); + 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()); } ///