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