diff --git a/Browser51Did/Browser51DidTestBase.cs b/Browser51Did/Browser51DidTestBase.cs
new file mode 100644
index 0000000..278f4fd
--- /dev/null
+++ b/Browser51Did/Browser51DidTestBase.cs
@@ -0,0 +1,280 @@
+#nullable enable
+
+using System;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using FiftyOne.Did.Model;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// A test method whose every result has the resource key taken out of it
+/// before the runner sees it, being the failure message, the stack trace
+/// and everything the test wrote.
+///
+/// This suite is public and its CI log is public, and a failure message
+/// here routinely quotes what a page did, which includes the client
+/// script's address, and that address names the resource key. Redacting
+/// each message by hand leaves the next message written to leak it, so it
+/// is done once, here, for every result.
+/// refuses to run a test that is not marked with this attribute.
+///
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+public sealed class Browser51DidTestAttribute : TestMethodAttribute
+{
+ /// Passes the declaring file and line on, as MSTest needs.
+ public Browser51DidTestAttribute(
+ [CallerFilePath] string callerFilePath = "",
+ [CallerLineNumber] int callerLineNumber = -1)
+ : base(callerFilePath, callerLineNumber)
+ {
+ }
+
+ ///
+ public override async Task ExecuteAsync(
+ ITestMethod testMethod)
+ {
+ var results = await base.ExecuteAsync(testMethod)
+ .ConfigureAwait(false);
+ for (var index = 0; index < results.Length; index++)
+ {
+ results[index] = Redacted(results[index]);
+ }
+ return results;
+ }
+
+ ///
+ /// A fresh result carrying everything the runner reports, with the
+ /// resource key taken out of it.
+ ///
+ /// A new result rather than an edit of the one MSTest built, because
+ /// setting on a result
+ /// that already has one does not replace it: MSTest keeps the first
+ /// and reports an AggregateException of both, and it has already put
+ /// the first one's message into a member of its own that this assembly
+ /// cannot set. Redacting by assignment therefore printed the key and
+ /// the redaction of it side by side — "One or more errors occurred.
+ /// (the key) (the redaction)" — and hid nothing. A result built here
+ /// has no first exception and no such member, so the runner reports
+ /// what is set below and nothing else. RedactionTests holds this.
+ ///
+ ///
+ private static TestResult Redacted(TestResult result)
+ => Redacted(result, Harness.Resource);
+
+ ///
+ /// The same, against a resource key given here rather than the
+ /// configured one, so the redaction can be tested.
+ ///
+ internal static TestResult Redacted(
+ TestResult result, string? resource)
+ => new()
+ {
+ DisplayName = RedactedOrNull(result.DisplayName, resource),
+ Outcome = result.Outcome,
+ Duration = result.Duration,
+ ExecutionId = result.ExecutionId,
+ ParentExecId = result.ParentExecId,
+ ResultFiles = result.ResultFiles,
+ LogOutput = RedactedOrNull(result.LogOutput, resource),
+ LogError = RedactedOrNull(result.LogError, resource),
+ DebugTrace = RedactedOrNull(result.DebugTrace, resource),
+ TestContextMessages =
+ RedactedOrNull(result.TestContextMessages, resource),
+ TestFailureException = RedactedFailure(result, resource),
+ };
+
+ ///
+ /// The failure to report, redacted where it names the resource key.
+ /// The original is kept where it does not, so a reader still sees the
+ /// exception the test actually threw.
+ ///
+ private static Exception? RedactedFailure(
+ TestResult result, string? resource)
+ {
+ var failure = result.TestFailureException;
+ if (failure is null || string.IsNullOrEmpty(resource))
+ {
+ return failure;
+ }
+ var whole = failure.ToString();
+ if (whole.Contains(resource, StringComparison.Ordinal) == false)
+ {
+ return failure;
+ }
+ // The exception cannot be edited, so a new one carries the redacted
+ // text. Its own stack trace would point here, so the original one is
+ // kept in the message, where the line that failed can still be read.
+ var text = Harness.Redacted(
+ $"{InnermostMessage(failure)}\nWhere it failed, kept because "
+ + $"the message was redacted:\n{whole}",
+ resource);
+ return result.Outcome == UnitTestOutcome.Inconclusive
+ ? new AssertInconclusiveException(text)
+ : new AssertFailedException(text);
+ }
+
+ private static string InnermostMessage(Exception failure)
+ {
+ var inner = failure;
+ while (inner.InnerException is not null)
+ {
+ inner = inner.InnerException;
+ }
+ return inner.Message;
+ }
+
+ private static string? RedactedOrNull(string? text, string? resource)
+ => text is null ? null : Harness.Redacted(text, resource);
+}
+
+///
+/// What every browser acceptance test class shares, being the guard, the
+/// skip when the harness is not configured, the demo, and the reading of a
+/// 51Did.
+///
+/// The guard runs once for each class from the set up, not from inside a
+/// test, so that a cloud built against the old client script fails every
+/// test in the class rather than letting one of them pass for the wrong
+/// reason.
+///
+///
+public abstract class Browser51DidTestBase
+{
+ ///
+ /// The browsers these tests are written for. Both are run where the
+ /// behaviour under test differs between them, which is anything the
+ /// shared choice travels on.
+ ///
+ protected const string Chrome = "Chrome";
+
+ /// The second browser, for the same reason.
+ protected const string Firefox = "Firefox";
+
+ /// Set by MSTest, and used to find the running test.
+ public TestContext TestContext { get; set; } = null!;
+
+ ///
+ /// Fails every test in the class unless the client script the demo's
+ /// pages load is the new one. See
+ /// .
+ ///
+ [ClassInitialize(InheritanceBehavior.BeforeEachDerivedClass)]
+ public static void GuardTheClientScript(TestContext context)
+ {
+ if (Harness.Configured == false)
+ {
+ // Nothing to guard. Each test reports inconclusive below and
+ // says which variables are missing.
+ return;
+ }
+ Harness.RequireTheNewClientScript();
+ }
+
+ [TestInitialize]
+ public void RequireHarness()
+ {
+ var method = GetType().GetMethod(TestContext.TestName ?? string.Empty);
+ if (method?.GetCustomAttribute() is null)
+ {
+ Assert.Fail(
+ $"{TestContext.TestName} is not marked [Browser51DidTest], "
+ + "so a failure in it could print the resource key into a "
+ + "public log. Mark it [Browser51DidTest] instead of "
+ + "[TestMethod].");
+ }
+ if (Harness.Configured == false)
+ {
+ Assert.Inconclusive(Harness.NotConfiguredReason);
+ }
+ Demo.Chosen.EnsureStarted();
+ }
+
+ ///
+ /// A browser, which will load the demo's pages as the two publisher
+ /// sites.
+ ///
+ protected static Visitor NewVisitor(string browser)
+ => new(
+ browser == Firefox ? Harness.NewFirefox() : Harness.NewChrome(),
+ browser);
+
+ ///
+ /// Refuses to go on where the cloud will not hold a choice for this
+ /// resource key, because the test would then be failing on the harness
+ /// rather than on the code. The service is asked rather than a flag
+ /// being read, so the reason reported is the service's own.
+ ///
+ protected static void RequireSharing() => Harness.RequireSharedStore();
+
+ ///
+ /// Refuses to go on where this resource key cannot create an
+ /// identifier for a standard or personalized answer, which needs a
+ /// resource key whose products include CloudV5FODiD. The service is
+ /// asked, and the skip carries its own reason.
+ ///
+ protected static void RequireMarketingIdentifiers()
+ => Harness.RequireMarketingIdentifiers();
+
+ #region Reading a 51Did
+
+ ///
+ /// Parses an identifier the page is holding, failing with what was
+ /// wrong when it is not one.
+ ///
+ protected static FodId Parse(string identifier, string what)
+ {
+ Assert.IsFalse(
+ string.IsNullOrWhiteSpace(identifier),
+ $"{what}: the page holds no identifier at all.");
+ Assert.IsTrue(
+ FodId.TryParse(identifier, out var parsed, out var status),
+ $"{what}: '{identifier}' is not a 51Did ({status}).");
+ Assert.IsNotNull(
+ parsed,
+ $"{what}: '{identifier}' parsed to nothing ({status}).");
+ return parsed!;
+ }
+
+ ///
+ /// The identifier says the usage was stated by the caller, which is
+ /// what PMP does, so the signal source is direct.
+ ///
+ protected static void AssertDirect(string identifier, string what)
+ {
+ var parsed = Parse(identifier, what);
+ Assert.IsFalse(
+ parsed.UsageFromConsent,
+ $"{what}: the usage should have been stated directly, so the "
+ + "flag saying it was decoded from a framework string must be "
+ + $"clear. The identifier reports usage {parsed.Usage}.");
+ }
+
+ ///
+ /// The identifier says the usage was decoded from a framework string,
+ /// which is what a consent platform page produces.
+ ///
+ protected static void AssertFromConsentString(
+ string identifier, string what, Usage expected)
+ {
+ var parsed = Parse(identifier, what);
+ Assert.IsTrue(
+ parsed.UsageFromConsent,
+ $"{what}: the usage came from a framework string, so the flag "
+ + "recording that must be set. The identifier reports usage "
+ + $"{parsed.Usage}.");
+ Assert.AreEqual(
+ expected,
+ parsed.Usage,
+ $"{what}: the framework string granted {expected}.");
+ }
+
+ /// The usage an identifier carries.
+ protected static Usage UsageOf(string identifier, string what)
+ => Parse(identifier, what).Usage;
+
+ #endregion
+}
diff --git a/Browser51Did/ConsentPlatformAcceptanceTests.cs b/Browser51Did/ConsentPlatformAcceptanceTests.cs
new file mode 100644
index 0000000..6870981
--- /dev/null
+++ b/Browser51Did/ConsentPlatformAcceptanceTests.cs
@@ -0,0 +1,180 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using FiftyOne.Did.Model;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// A page with a consent platform on it and no PMP, which is the case
+/// where the identifier's usage was decoded from a framework string
+/// rather than stated.
+///
+/// The consent platform here is a stub, and that is the contract rather
+/// than a gap. The client script uses only what the framework's
+/// specification requires of every platform, being ping,
+/// addEventListener and a callback carrying tcString and eventStatus, so a
+/// stub honouring those is what every product has to do, and a product
+/// that breaks it is the product's defect. The two never share a page, so
+/// nothing here also carries PMP.
+///
+///
+[TestClass, TestCategory("Browser51Did")]
+public class ConsentPlatformAcceptanceTests : Browser51DidTestBase
+{
+ ///
+ /// The publisher writes nothing but the script tag and the stub the
+ /// framework's own specification already tells them to write. The
+ /// answer arrives after the script's first round, which is the common
+ /// case, and the identifier that comes back records that the usage was
+ /// decoded rather than stated.
+ ///
+ [Browser51DidTest]
+ public void ConsentPlatformOnly_SecondRequestCarriesTheString()
+ {
+ RequireMarketingIdentifiers();
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.Consent);
+ visitor.WaitForClientRounds(1);
+ Assert.IsTrue(
+ visitor.HasTheNewClientScript(),
+ "the page is running a client script with no user prompt block "
+ + "in it, so nothing here would be proving the new behaviour.");
+
+ var first = visitor.ClientRequests()[0];
+ Assert.IsNull(
+ first.Form("id.usage"),
+ "no answer has been given yet, and a page with a consent "
+ + "platform on it means unknown rather than none, so nothing "
+ + $"may be stated. The body was: {first.Body}");
+ Assert.AreEqual(
+ "",
+ visitor.Identifier(),
+ "no answer means no identifier.");
+
+ // The visitor answers the consent platform, which delivers the
+ // string to whoever registered. The test decides when, so the
+ // ordering is asserted rather than timed.
+ var listeners = visitor.Script(
+ "return window.__51dCmp.deliver();");
+ Assert.IsTrue(
+ listeners > 0,
+ "the client script must have registered a listener with the "
+ + "consent platform. Nothing was registered, so the answer "
+ + "reached nobody. The console said: "
+ + string.Join(" | ", visitor.Console()));
+
+ Harness.Until(
+ () => visitor.ClientRequests().Count(r => r.Done) >= 2,
+ "the client script asked again once the answer arrived. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+
+ var second = visitor.ClientRequests().Last();
+ Assert.IsNotNull(
+ second.Form("tcstring"),
+ "the framework string PMP delivered must reach the "
+ + $"cloud. The body was: {second.Body}");
+ // The demo's stub consent platform delivers one fixed string, and
+ // every language's demo has to deliver the same one, so it is
+ // named here rather than trusted.
+ Assert.AreEqual(
+ TcString.Personalized(),
+ second.Form("tcstring"),
+ "the stub consent platform on the demo's consent page must "
+ + "deliver the string granting every purpose, built by "
+ + $"TcString.Personalized(). The body was: {second.Body}");
+ Assert.IsNull(
+ second.Form("id.usage"),
+ "the client script must not decide the usage itself. A string "
+ + "goes as a string and the server decodes it, which is what "
+ + "makes the flag mean something. The body was: "
+ + second.Body);
+ Assert.AreEqual(
+ 1,
+ second.FormCount("tcstring"),
+ "the string must be sent once, because the server takes the "
+ + "first value of a repeated key and warns. The body was: "
+ + second.Body);
+
+ Harness.Until(
+ () => visitor.Identifier() != "",
+ "an identifier came back once the answer had been decoded. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+ Assert.IsTrue(
+ second.Response.Contains("fodid", StringComparison.Ordinal),
+ "the response to the request carrying the string must carry the "
+ + "identifier section.");
+ AssertFromConsentString(
+ visitor.Identifier(),
+ "the usage was decoded from the framework string",
+ Usage.Personalized);
+ }
+
+ ///
+ /// A page with nowhere to get an answer from creates
+ /// nothing, says so once, and stores a record that carries no answer,
+ /// so a later page view with an answer is a different input and asks
+ /// again rather than reusing this one.
+ ///
+ [Browser51DidTest]
+ public void NoPmpAtAll_NoIdentifierAndOneWarning()
+ {
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.NoPlatform);
+ visitor.WaitForClientRounds(1);
+ Assert.IsTrue(
+ visitor.HasTheNewClientScript(),
+ "the page is running a client script with no user prompt block "
+ + "in it, so it would have nothing to warn about.");
+
+ var requests = visitor.ClientRequests();
+ Assert.AreEqual(
+ 1,
+ requests.Count,
+ "with nowhere to get an answer from there is nothing to come "
+ + "back for, so one request is all there should be. The "
+ + "requests were: "
+ + string.Join(" || ", requests.Select(r => r.ToString())));
+ Assert.IsNull(
+ requests[0].Form("id.usage"),
+ $"nobody was asked. The body was: {requests[0].Body}");
+ Assert.IsNull(
+ requests[0].Form("tcstring"),
+ $"there is no platform to ask. The body was: {requests[0].Body}");
+ Assert.AreEqual(
+ "",
+ visitor.Identifier(),
+ "a page where nobody was asked produces no identifier, which is "
+ + "the intended outcome and not a fault.");
+
+ var record = visitor.CacheRecord();
+ Assert.IsNotNull(
+ record,
+ "the client script stores the inputs of the request it made, so "
+ + "that a later page view with a different input asks again. "
+ + "Nothing was stored.");
+ Assert.IsFalse(
+ record!.Contains("id.usage", StringComparison.Ordinal),
+ "the stored record must carry no answer, or a page view that "
+ + "does have one would look the same as this and be served "
+ + $"from the cache. The record was: {record}");
+
+ var warnings = visitor.ConsoleMatching(Harness.NoPmpMessage);
+ Assert.AreEqual(
+ 1,
+ warnings.Count,
+ "the warning that there is no platform on the page is logged "
+ + "once for the page view, not once per round. The console "
+ + "said: " + string.Join(" | ", visitor.Console()));
+ foreach (var empty in new[] { "undefined", "null" })
+ {
+ Assert.IsFalse(
+ warnings[0].Contains(empty, StringComparison.OrdinalIgnoreCase),
+ "the warning names nothing it could not find a value for, "
+ + $"so '{empty}' must not appear in it. It said: "
+ + warnings[0]);
+ }
+ }
+}
diff --git a/Browser51Did/Demo.cs b/Browser51Did/Demo.cs
new file mode 100644
index 0000000..82e5bec
--- /dev/null
+++ b/Browser51Did/Demo.cs
@@ -0,0 +1,279 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using FiftyOne.Pipeline.Cloud.SeleniumTests.Examples;
+using FiftyOne.Pipeline.Cloud.Tests.Common.Helpers;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// The routes every language's demo serves, as the dotnet demo in
+/// device-detection-dotnet-examples defines them. A test names the page it
+/// loads by one of these and nothing else, so the same test drives any
+/// demo that serves the same pages.
+///
+public static class Routes
+{
+ /// PMP, then the client script.
+ public const string Common = "common";
+
+ /// The client script, then PMP.
+ public const string CommonScriptFirst = "common-script-first";
+
+ /// PMP, the client script and the change watcher.
+ public const string Change = "change";
+
+ /// The first of two pages carrying what does.
+ public const string TwoOne = "two/one";
+
+ /// The second of those two pages.
+ public const string TwoTwo = "two/two";
+
+ /// The stub consent platform, then the client script.
+ public const string Consent = "consent";
+
+ /// The client script alone.
+ public const string NoPlatform = "no-platform";
+
+ /// PMP alone, with no client script tag.
+ public const string PlatformOnly = "platform-only";
+
+ ///
+ /// PMP alone, naming the client script's object
+ /// .
+ ///
+ public const string NamedObject = "named-object";
+}
+
+///
+/// How a demo's pages get their client script, being the route prefix the
+/// demo serves them under.
+///
+public sealed class DemoMode
+{
+ ///
+ /// Pages that load the client script straight from the cloud, which
+ /// posts its evidence to the cloud's own endpoint.
+ ///
+ public static readonly DemoMode Cloud = new("cloud", null);
+
+ ///
+ /// Pages whose client script the demo's own pipeline serves, which
+ /// posts to that pipeline's endpoint, being the path the demo's
+ /// descriptor names.
+ ///
+ public static readonly DemoMode Pipeline = new("pipeline", "descriptor");
+
+ /// The route prefix, and the name DEMO_MODE takes.
+ public string Name { get; }
+
+ private readonly string? _pathFromDescriptor;
+
+ private DemoMode(string name, string? pathFromDescriptor)
+ {
+ Name = name;
+ _pathFromDescriptor = pathFromDescriptor;
+ }
+
+ ///
+ /// The path the client script posts its evidence to on this mode's
+ /// pages, which is how a test tells the client script's own requests
+ /// from everything else a page does.
+ ///
+ public string JsonEndpointPath(ExampleDescriptor? descriptor)
+ => _pathFromDescriptor is null
+ ? "/api/v4/json"
+ : descriptor?.JsonEndpointPath
+ ?? throw new InvalidOperationException(
+ "The pipeline mode needs a registered demo, because its "
+ + "descriptor names the path the client script posts to.");
+}
+
+///
+/// The demo under test, started once for the whole run.
+///
+/// It is the demo at DEMO_URL where that is set, and otherwise the DEMO_LANG
+/// demo, dotnet unless another is named, which the suite launches from its
+/// sibling checkout through , the way
+/// the Contract tests launch an example. It is started with exactly the two
+/// input variables every language's demo reads, 51DEGREES_RESOURCE_KEY and
+/// 51DEGREES_CLOUD_ENDPOINT, taken from this run's own environment (the key
+/// from _51DEGREES_RESOURCE_KEY_51DID where 51DEGREES_RESOURCE_KEY is
+/// unset), and with the runtime's own way of choosing a port. DEMO_MODE chooses the
+/// route prefix, cloud unless pipeline is named.
+///
+///
+public sealed class Demo
+{
+ private static readonly Lazy ChosenOnce =
+ new(Choose, LazyThreadSafetyMode.ExecutionAndPublication);
+
+ ///
+ /// The demo this run tests. Deciding starts nothing, and
+ /// starts it.
+ ///
+ public static Demo Chosen => ChosenOnce.Value;
+
+ private readonly IExampleApp? _app;
+ private readonly ExampleDescriptor? _descriptor;
+ private readonly string? _unavailable;
+ private readonly object _startLock = new();
+ private bool _started;
+ private string? _startFailure;
+
+ /// Which pages are loaded.
+ public DemoMode Mode { get; }
+
+ /// The demo's name, for a message.
+ public string Name { get; }
+
+ private Demo(
+ IExampleApp? app,
+ ExampleDescriptor? descriptor,
+ DemoMode mode,
+ string name,
+ string? unavailable)
+ {
+ _app = app;
+ _descriptor = descriptor;
+ Mode = mode;
+ Name = name;
+ _unavailable = unavailable;
+ }
+
+ private static Demo Choose()
+ {
+ var modeName = Environment.GetEnvironmentVariable("DEMO_MODE");
+ var mode = string.Equals(
+ modeName, DemoMode.Pipeline.Name, StringComparison.OrdinalIgnoreCase)
+ ? DemoMode.Pipeline
+ : DemoMode.Cloud;
+ var name = $"the {ExampleApps.SelectedDemoLang} demo's /{mode.Name}/ pages";
+ return ExampleApps.TryCreateDemo(
+ out var app, out var descriptor, out var skipReason)
+ ? new Demo(app, descriptor, mode, name, null)
+ : new Demo(null, descriptor, mode, name, skipReason);
+ }
+
+ ///
+ /// The path the client script posts its evidence to on the pages being
+ /// loaded.
+ ///
+ public string JsonEndpointPath => Mode.JsonEndpointPath(_descriptor);
+
+ ///
+ /// Starts the demo where it has not been started, once for the whole
+ /// run, because launching one costs far more than any test here. A demo
+ /// that fails to start fails every test with the same reason.
+ ///
+ public void EnsureStarted()
+ {
+ lock (_startLock)
+ {
+ if (_startFailure != null)
+ {
+ Assert.Fail(_startFailure);
+ }
+ if (_started)
+ {
+ return;
+ }
+ if (_app is null)
+ {
+ _startFailure = $"No demo can be started. {_unavailable}";
+ Assert.Fail(_startFailure);
+ return;
+ }
+ try
+ {
+ _app.StartAsync(
+ new ExampleAppOptions(
+ TestHelpers.GetRandomUnusedPort(),
+ new Uri(Harness.CloudUrl + "/"),
+ Harness.Resource!,
+ new Dictionary()),
+ CancellationToken.None)
+ .GetAwaiter().GetResult();
+ _started = true;
+ }
+ catch (Exception error)
+ {
+ // The demo's own output is in the message, and a page it
+ // logged could carry the resource key.
+ _startFailure = Harness.Redacted(
+ $"{Name} could not be started, so no test ran against "
+ + $"it. {error.Message}");
+ Assert.Fail(_startFailure);
+ }
+ }
+ }
+
+ ///
+ /// Stops the demo where this run started it, and does nothing where no
+ /// test ever asked for one, so a run of another category is left alone.
+ ///
+ public static void StopIfStarted()
+ {
+ if (ChosenOnce.IsValueCreated == false)
+ {
+ return;
+ }
+ var chosen = ChosenOnce.Value;
+ lock (chosen._startLock)
+ {
+ if (chosen._app is null || chosen._started == false)
+ {
+ return;
+ }
+ chosen._app.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ chosen._started = false;
+ }
+ }
+
+ ///
+ /// The address of one of the demo's pages as one of the publisher
+ /// sites. The cache buster is on every navigation, because a page held
+ /// in the browser's cache would carry the recorder from an earlier test.
+ ///
+ public string PageUrl(string site, string route)
+ {
+ EnsureStarted();
+ return $"http://{site}:{_app!.BaseUrl.Port}/{Mode.Name}/{route}"
+ + $"?v={DateTime.UtcNow.Ticks}";
+ }
+
+ ///
+ /// Where the guard fetches the client script from, server to server,
+ /// being what the pages load. On the cloud pages that is the cloud's
+ /// script for the resource key, and on the pipeline pages it is the
+ /// demo's own.
+ ///
+ public string ClientScriptFetchUrl()
+ {
+ if (Mode == DemoMode.Cloud)
+ {
+ return $"{Harness.CloudUrl}/api/v4/{Harness.Resource}.js";
+ }
+ EnsureStarted();
+ return new Uri(_app!.BaseUrl, "51Degrees.core.js").ToString();
+ }
+}
+
+///
+/// Stops the demo this run started once every test has finished.
+///
+[TestClass]
+public class Browser51DidRunCleanup
+{
+ ///
+ /// MSTest allows one assembly clean up in a test assembly and this is
+ /// it, so anything else that has to happen at the end of a run belongs
+ /// in this method rather than beside it. A demo left running would hold
+ /// its port after the run.
+ ///
+ [AssemblyCleanup]
+ public static void StopTheDemo() => Demo.StopIfStarted();
+}
diff --git a/Browser51Did/DemoSettingsTests.cs b/Browser51Did/DemoSettingsTests.cs
new file mode 100644
index 0000000..4bce2db
--- /dev/null
+++ b/Browser51Did/DemoSettingsTests.cs
@@ -0,0 +1,79 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using FiftyOne.Pipeline.Cloud.Tests.Common;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// Which of its two names the demo's resource key is read from. The rules
+/// are read through a lookup the test supplies, so nothing here changes the
+/// environment of the run around it.
+///
+/// These exist because the key was once read straight from the process
+/// environment while every other setting went through the lookup a
+/// is given, so a configuration built for a test
+/// quietly ignored what the test gave it for this one setting.
+///
+///
+[TestClass, TestCategory("Browser51Did")]
+public class DemoSettingsTests
+{
+ private const string RuntimeKey = "NOTAKEYFROMTHERUNTIMENAME";
+ private const string CiKey = "NOTAKEYFROMTHECINAME";
+
+ private static TestConfig Config(params string[] namesAndValues)
+ {
+ var values = new Dictionary();
+ for (var index = 0; index < namesAndValues.Length; index += 2)
+ {
+ values[namesAndValues[index]] = namesAndValues[index + 1];
+ }
+ return new TestConfig(
+ name => values.TryGetValue(name, out var value) ? value : null!);
+ }
+
+ ///
+ /// A developer's own key wins over the one continuous integration set,
+ /// because the runtime name is the one every language's demo reads.
+ ///
+ [TestMethod]
+ public void BothNamesSet_TheRuntimeNameWins()
+ {
+ var config = Config(
+ TestConfig.DemoResourceKeyVariable, RuntimeKey,
+ TestConfig.DemoResourceKeyCiVariable, CiKey);
+ Assert.AreEqual(RuntimeKey, config.DemoResourceKey);
+ }
+
+ ///
+ /// Where only continuous integration's name is set, that is used, and
+ /// an empty runtime name counts as unset.
+ ///
+ [TestMethod]
+ public void OnlyTheCiNameSet_ItIsUsed()
+ {
+ var config = Config(
+ TestConfig.DemoResourceKeyVariable, "",
+ TestConfig.DemoResourceKeyCiVariable, CiKey);
+ Assert.AreEqual(CiKey, config.DemoResourceKey);
+ }
+
+ ///
+ /// Neither set fails naming both, in the order they are read, so the
+ /// reader knows either will do.
+ ///
+ [TestMethod]
+ public void NeitherNameSet_TheFailureNamesBoth()
+ {
+ var config = Config();
+ var failure = Assert.ThrowsExactly(
+ () => _ = config.DemoResourceKey);
+ StringAssert.Contains(
+ failure.Message,
+ $"'{TestConfig.DemoResourceKeyVariable}', or where that is "
+ + $"unset '{TestConfig.DemoResourceKeyCiVariable}'");
+ }
+}
diff --git a/Browser51Did/Harness.cs b/Browser51Did/Harness.cs
new file mode 100644
index 0000000..4cb1632
--- /dev/null
+++ b/Browser51Did/Harness.cs
@@ -0,0 +1,764 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using FiftyOne.Pipeline.Cloud.SeleniumTests.Helpers;
+using FiftyOne.Pipeline.Cloud.Tests.Common;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Chrome;
+using OpenQA.Selenium.Firefox;
+using OpenQA.Selenium.Remote;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// Everything the browser acceptance tests share, being where the cloud
+/// is, which resource key to use, the two site names a cross site test
+/// needs, the browsers, and the guard that stops a test running against
+/// the old client script.
+///
+/// The thing under test is a demo, a web app serving the pages these tests
+/// load, which every language mirrors, started with a cloud endpoint and a
+/// resource key. PMP and the shared store come from
+/// that cloud whichever demo is chosen. See .
+///
+///
+public static class Harness
+{
+ ///
+ /// A setting read through the suite's own ,
+ /// with the message it gives where the variable is missing.
+ ///
+ private readonly record struct Setting(string? Value, string? Missing);
+
+ private static Setting Read(Func require)
+ {
+ try
+ {
+ return new Setting(require(), null);
+ }
+ catch (InvalidOperationException missing)
+ {
+ return new Setting(null, missing.Message);
+ }
+ }
+
+ private static readonly Setting Endpoint =
+ Read(() => TestConfig.Instance().DemoCloudEndpoint);
+
+ private static readonly Setting ResourceKey =
+ Read(() => TestConfig.Instance().DemoResourceKey);
+
+ ///
+ /// The cloud, for example https://localhost:5443, with no trailing
+ /// slash, taken from 51DEGREES_CLOUD_ENDPOINT, the variable the demo is
+ /// started with. That endpoint names the api/v4 path, as every reader of
+ /// it expects, and the path is taken off here, because what the tests
+ /// ask the cloud directly they build from its root. An endpoint written
+ /// without the path is taken as the root.
+ ///
+ public static string? CloudUrl
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(Endpoint.Value))
+ {
+ return null;
+ }
+ var root = Endpoint.Value.TrimEnd('/');
+ const string ApiPath = "/api/v4";
+ return root.EndsWith(ApiPath, StringComparison.OrdinalIgnoreCase)
+ ? root.Substring(0, root.Length - ApiPath.Length)
+ : root;
+ }
+ }
+
+ ///
+ /// The resource key the demo is started with, from
+ /// 51DEGREES_RESOURCE_KEY, the variable every language's demo reads
+ /// first, or where that is unset from _51DEGREES_RESOURCE_KEY_51DID,
+ /// the name continuous integration sets. The demo is given it as
+ /// 51DEGREES_RESOURCE_KEY either way.
+ ///
+ /// The tests that create a 51Did for a standard or personalized answer
+ /// need a resource key whose products include CloudV5FODiD.
+ /// asks the service first,
+ /// and where the key cannot create, those tests skip with the service's
+ /// own reason.
+ ///
+ ///
+ public static string? Resource => ResourceKey.Value;
+
+ ///
+ /// Whether the harness is configured at all. Unset means a developer
+ /// ran the category without saying where the cloud is or which key to
+ /// use, so the tests report inconclusive rather than failing, the way
+ /// the suite's other tests do when their configuration is missing.
+ ///
+ public static bool Configured =>
+ string.IsNullOrEmpty(CloudUrl) == false
+ && string.IsNullOrEmpty(Resource) == false;
+
+ /// Why the harness is not configured, for the skip.
+ public static string NotConfiguredReason =>
+ "The 51Did browser acceptance tests are not configured. "
+ + string.Join(
+ " ",
+ new[]
+ {
+ string.IsNullOrEmpty(CloudUrl) ? Endpoint.Missing : null,
+ string.IsNullOrEmpty(Resource) ? ResourceKey.Missing : null,
+ }.Where(missing => missing != null))
+ + " 51DEGREES_CLOUD_ENDPOINT names the cloud including its api/v4 "
+ + "path, and 51DEGREES_RESOURCE_KEY, or where that is unset "
+ + "_51DEGREES_RESOURCE_KEY_51DID, a resource key the cloud "
+ + "creates 51Dids for standard and personalized answers with. Both "
+ + "are handed to the demo under the first names. See "
+ + "Browser51Did/README.md.";
+
+ ///
+ /// The object name a page uses when it does not ask for another one,
+ /// which is what the client script and PMP both
+ /// fall back to.
+ ///
+ public const string DefaultObjectName = "fod";
+
+ ///
+ /// The object name the demo's page
+ /// gives PMP in data-object-name.
+ ///
+ public const string NamedObject = "fiftyOneData";
+
+ ///
+ /// The first publisher site. It is a name and not localhost, because
+ /// the cloud is on localhost and a page there would be the same site
+ /// as the cloud, which would make the shared cookie a first party one
+ /// and prove nothing.
+ ///
+ public const string SiteA = "site-a.localtest";
+
+ ///
+ /// The second publisher site, for the cross site case. A different
+ /// registrable name from , so a browser treats the
+ /// two as different sites rather than as one.
+ ///
+ /// The top level domain is one nothing owns, so neither name can ever
+ /// resolve on the network and neither can be reached by accident. The
+ /// browsers are told to resolve both to this machine, and the
+ /// development certificate used when the service is run outside a
+ /// container names both, so the same two names work whichever way the
+ /// service under test was started.
+ ///
+ ///
+ public const string SiteB = "site-b.localtest";
+
+ ///
+ /// How long any wait in these tests is given before it is called a
+ /// failure. Nothing is asserted on a clock, so this is only the point
+ /// at which a test stops waiting for something that is never going to
+ /// happen.
+ ///
+ public static readonly TimeSpan Patience = TimeSpan.FromSeconds(30);
+
+ #region Console messages the tests look for
+
+ ///
+ /// Text that exists only in the client script's new user prompt block,
+ /// being the message it logs once a page view has reached the server's
+ /// maximum number of rounds. The guard below refuses to let any test
+ /// in this namespace run unless the script served by the implementation
+ /// under test carries it, which is what makes a green run mean
+ /// something.
+ ///
+ /// This is the one place to change if that wording changes.
+ ///
+ ///
+ public const string IterationLimitMessage = "51Degrees: the maximum of";
+
+ ///
+ /// Text that exists only inside the template's user prompt section,
+ /// being the name PMP puts its own surface under.
+ /// The iteration limit message above sits outside that section, so it
+ /// says the template is the new one and says nothing about whether the
+ /// block was rendered. This says the block is there.
+ ///
+ public const string UserPromptBlockMarker = "__51d_pmp";
+
+ ///
+ /// The warning the client script logs when a page carries no place to
+ /// get an answer from, so no identifier can be created. Read off the
+ /// script the endpoint served on 15 September 2026, where the sentence
+ /// is "51Degrees: no preference platform was found on this page. A
+ /// platform's stub must precede this script. No 51Did will be created
+ /// until a platform answers." Matched as a substring, so the rest of
+ /// the sentence may change, and the test using it also asserts that no
+ /// value was printed beside it.
+ ///
+ public const string NoPmpMessage =
+ "no preference platform was found";
+
+ ///
+ /// The warning the client script logs when a second copy of itself is
+ /// loaded onto one page under the same object name, where the sentence
+ /// is "51Degrees: fod already exists on this page. Loading the script
+ /// twice replaces it. Load it once and call fod.refresh() to update."
+ /// The object's name is in it, so only the part that is the same
+ /// whatever the object is called is matched. Two tests assert it was
+ /// NOT logged, which is how they say that nothing added a second copy.
+ ///
+ public const string SecondInstanceWarning =
+ "already exists on this page";
+
+ ///
+ /// What PMP says when it finds no client script
+ /// object on the page. Read from PMP's own source, where the
+ /// sentence is "There is no client script object named '{name}' on
+ /// this page, so the client script is being added from the cloud that
+ /// served this one."
+ ///
+ public const string NoClientScriptMessage =
+ "no client script object named";
+
+ ///
+ /// And what it says when it goes on to add one, which is the half a
+ /// publisher who left the tag out has to see.
+ ///
+ public const string AddingClientScriptMessage =
+ "client script is being added";
+
+ #endregion
+
+
+ ///
+ /// Server to server, with certificate validation relaxed because the
+ /// cloud under test may serve a certificate issued for another name.
+ /// The browsers are told to accept it for the same reason.
+ ///
+ private static readonly HttpClient Reader = new(
+ new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback =
+ (message, certificate, chain, errors) => true,
+ })
+ {
+ Timeout = TimeSpan.FromSeconds(60),
+ };
+
+ private static readonly object GuardLock = new();
+ private static string? _guardEvidence;
+ private static string? _guardFailure;
+
+ ///
+ /// The guard. Fetches the client script the demo's pages load
+ /// before the tests drive a browser at them, and refuses to go on unless
+ /// the body carries both of the markers below.
+ ///
+ /// Two markers, because they say different things. The iteration limit
+ /// message says the template is the new one. It sits outside the user
+ /// prompt section, so it is in the rendered script whenever updates
+ /// are enabled, whether or not the block itself was rendered. PMP's
+ /// own global name appears only inside the section, so it
+ /// is what says the block is actually there. A run that had only the
+ /// first would be testing the new template with the block switched
+ /// off, which passes nothing it is meant to prove.
+ ///
+ ///
+ /// It runs once per class from the set up rather than inside a test,
+ /// so no test here can run against the old script and report green. A
+ /// run against a cloud built on the released 4.5.104 package fails every
+ /// test in the class with the line below, which says what was served
+ /// and what was wanted. The answer is kept, so it is asked once a run.
+ ///
+ ///
+ public static void RequireTheNewClientScript()
+ {
+ lock (GuardLock)
+ {
+ if (_guardFailure != null)
+ {
+ Assert.Fail(_guardFailure);
+ }
+ if (_guardEvidence != null)
+ {
+ Console.WriteLine(_guardEvidence);
+ return;
+ }
+ var implementation = Demo.Chosen;
+ var url = implementation.ClientScriptFetchUrl();
+ string body;
+ try
+ {
+ body = Reader.GetStringAsync(url).Result;
+ }
+ catch (Exception error)
+ {
+ _guardFailure =
+ "The client script could not be fetched from "
+ + $"{Redacted(url)}, "
+ + "so there is no way to tell which template "
+ + $"{implementation.Name} was built from. "
+ + Redacted(error.Message);
+ Assert.Fail(_guardFailure);
+ return;
+ }
+ var evidence = new List();
+ foreach (var (marker, says) in RequiredMarkers)
+ {
+ var found = body.IndexOf(marker, StringComparison.Ordinal);
+ if (found < 0)
+ {
+ _guardFailure =
+ "The client script served by "
+ + $"{implementation.Name} at {Redacted(url)} does "
+ + $"not carry '{marker}', which is what says "
+ + $"{says}. Every test would be proving the wrong "
+ + "thing, so none of them runs. "
+ + Remedy(implementation)
+ + $" The script was {body.Length} bytes.";
+ Assert.Fail(_guardFailure);
+ return;
+ }
+ evidence.Add(
+ $"'{marker}' at {found}: {Around(body, found)}");
+ }
+ _guardEvidence =
+ $"Client script guard passed for {implementation.Name}. "
+ + $"{Redacted(url)} served {body.Length} "
+ + "bytes carrying " + string.Join(" and ", evidence);
+ Console.WriteLine(_guardEvidence);
+ }
+ }
+
+ /// What to do about a client script without the markers.
+ private static string Remedy(Demo demo)
+ => demo.Mode == DemoMode.Cloud
+ ? "Move the cloud's FiftyOne.Pipeline.JavaScriptBuilder pin to "
+ + "the package built from the new template and build the cloud "
+ + "again."
+ : $"Build {demo.Name} against a JavaScript builder carrying the "
+ + "new template, with a 51Did element in its pipeline, which "
+ + "is what makes the user prompt block render, and start it "
+ + "again.";
+
+ ///
+ /// What the served client script has to carry, and what each one
+ /// proves. See for why one is
+ /// not enough.
+ ///
+ private static readonly (string Marker, string Says)[] RequiredMarkers =
+ {
+ (IterationLimitMessage, "the template is the new one"),
+ (UserPromptBlockMarker, "the user prompt block was rendered"),
+ };
+
+ ///
+ /// The guard's evidence, for a report that has to quote it. Null until
+ /// has run and passed.
+ ///
+ public static string? GuardEvidence => _guardEvidence;
+
+ ///
+ /// Asks a check until it is happy or until it has asked enough times
+ /// to say the answer is settled.
+ ///
+ /// A service that has just come up answers the first request of a kind
+ /// before everything behind it is warm, and one cold answer is not a
+ /// refusal. A real refusal reads the same every time, so this costs a
+ /// few seconds there and rescues a run that would otherwise skip every
+ /// test on a first answer nobody would have accepted.
+ ///
+ ///
+ private static string? Settled(Func check)
+ {
+ string? refusal = null;
+ for (var attempt = 0; attempt < 6; attempt++)
+ {
+ refusal = check();
+ if (refusal == null)
+ {
+ return null;
+ }
+ Thread.Sleep(2000);
+ }
+ return refusal;
+ }
+
+ private static string? _marketingRefusal;
+ private static bool _marketingChecked;
+
+ ///
+ /// Refuses to go on where this resource key cannot create an
+ /// identifier for a marketing answer, which is what standard and
+ /// personalized both are.
+ ///
+ /// The cloud is asked rather than a flag being read, so the reason the
+ /// test reports is the service's own words rather than a guess, and a
+ /// key that stops carrying the product later says so instead of
+ /// failing somewhere in a browser. It is the cloud that is asked
+ /// whichever implementation is under test, because an example creates
+ /// nothing itself and passes the request on to the cloud.
+ ///
+ ///
+ public static void RequireMarketingIdentifiers()
+ {
+ lock (GuardLock)
+ {
+ if (_marketingChecked == false)
+ {
+ _marketingChecked = true;
+ _marketingRefusal = Settled(MarketingRefusal);
+ }
+ }
+ if (_marketingRefusal != null)
+ {
+ Assert.Inconclusive(
+ "This resource key cannot create an identifier for a "
+ + "marketing answer, which is what standard and "
+ + "personalized both are, so nothing here would be "
+ + "testing the thing it is for. The service said: "
+ + Redacted(_marketingRefusal)
+ + " Point 51DEGREES_RESOURCE_KEY, or in continuous "
+ + "integration _51DEGREES_RESOURCE_KEY_51DID, at a "
+ + "resource key the service creates standard identifiers "
+ + "for.");
+ }
+ }
+
+ ///
+ /// Asks the cloud to make a standard identifier and reports why it
+ /// would not, or null where it did.
+ ///
+ private static string? MarketingRefusal()
+ {
+ var url = $"{CloudUrl}/api/v4/json?resource={Resource}"
+ + "&id.usage=standard&values=FODiD.IdProbGlobal";
+ string body;
+ try
+ {
+ body = Reader.GetStringAsync(url).Result;
+ }
+ catch (Exception error)
+ {
+ return $"the service could not be asked. {error.Message}";
+ }
+ JsonElement fodid;
+ try
+ {
+ var root = JsonDocument.Parse(body).RootElement;
+ if (root.TryGetProperty("fodid", out fodid) == false)
+ {
+ return "the response carried no fodid section at all, so "
+ + "the key is not entitled to the identifier "
+ + "properties.";
+ }
+ }
+ catch (JsonException)
+ {
+ return $"the response was not readable. {Truncate(body)}";
+ }
+ if (fodid.TryGetProperty("idprobglobal", out var value)
+ && value.ValueKind == JsonValueKind.String)
+ {
+ return null;
+ }
+ return fodid.TryGetProperty(
+ "idprobglobalnullreason", out var reason)
+ ? reason.GetString()
+ : "no identifier came back and no reason was given.";
+ }
+
+ private static string? _sharedStoreRefusal;
+ private static bool _sharedStoreChecked;
+
+ ///
+ /// Refuses to go on where the shared store will not take a write for
+ /// this resource key. The store's cookie is named from the customer's
+ /// licence key, and the controller answers a record carrying none with
+ /// one refusal, so asking it once is what tells the tests whether a
+ /// choice can be carried between sites at all.
+ ///
+ public static void RequireSharedStore()
+ {
+ lock (GuardLock)
+ {
+ if (_sharedStoreChecked == false)
+ {
+ _sharedStoreChecked = true;
+ _sharedStoreRefusal = Settled(SharedStoreRefusal);
+ }
+ }
+ if (_sharedStoreRefusal != null)
+ {
+ Assert.Inconclusive(
+ "The cloud will not hold a choice for this resource key, "
+ + "so a choice cannot be carried between sites and nothing "
+ + "here would be testing that it is. The service said: "
+ + Redacted(_sharedStoreRefusal));
+ }
+ }
+
+ ///
+ /// Offers the shared store a write and reports why it would not take
+ /// it, or null where it did. Nothing is kept, because the answer's
+ /// cookie goes to this client rather than to any browser and this
+ /// client holds none.
+ ///
+ private static string? SharedStoreRefusal()
+ {
+ try
+ {
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post, $"{CloudUrl}/api/v4/pmp/pref")
+ {
+ Content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["resource"] = Resource ?? string.Empty,
+ ["preference"] = "standard",
+ ["network"] = NetworkName,
+ }),
+ };
+ request.Headers.Referrer = new Uri($"http://{SiteA}/");
+ using var response = Reader.Send(request);
+ if (response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+ var body = response.Content.ReadAsStringAsync().Result;
+ return $"{(int)response.StatusCode} {Truncate(body)}";
+ }
+ catch (Exception error)
+ {
+ return error.Message;
+ }
+ }
+
+ ///
+ /// Refuses to go on where a browser would not keep the shared choice's
+ /// cookie at all, so a choice could not be carried between sites
+ /// whatever the code did.
+ ///
+ /// The cloud sets that cookie Secure and SameSite=None, and a browser
+ /// keeps such a cookie only from a secure origin. HTTPS is one, and so
+ /// is localhost, which browsers treat as secure whatever the scheme.
+ /// Measured on 15 September 2026 against a service on
+ /// http://localhost:5050, both browsers kept the cookie and both tests
+ /// that call this passed. So what is refused is a cloud reached over
+ /// plain HTTP by any other name, and the suite's usual target,
+ /// http://localhost:8080, is not refused. Other loopback names such as
+ /// 127.0.0.1 were not measured, so they are refused rather than
+ /// assumed.
+ ///
+ ///
+ public static void RequireSecureCookies()
+ {
+ var cloud = new Uri(CloudUrl!);
+ if (cloud.Scheme == Uri.UriSchemeHttps
+ || string.Equals(
+ cloud.Host, "localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+ Assert.Inconclusive(
+ $"The cloud is at {Redacted(CloudUrl!)}, which is plain HTTP "
+ + "and not localhost, so a browser will not keep the shared "
+ + "choice's cookie, which the cloud sets Secure and "
+ + "SameSite=None. A choice cannot be carried between sites "
+ + "there, so nothing here would be testing that it is. Point "
+ + "51DEGREES_CLOUD_ENDPOINT at the cloud's HTTPS listener to run "
+ + "this.");
+ }
+
+ ///
+ /// The group of sites a choice is shared across in these tests. The
+ /// name is what the cookie holding the choice is derived from, so the
+ /// pages and the check above have to use the same one.
+ ///
+ public const string NetworkName = "Fifty One Network";
+
+ private static string Truncate(string value)
+ => value.Length <= 300 ? value : value.Substring(0, 300) + "...";
+
+ ///
+ /// Text with the resource key taken out of it, for anything a test
+ /// prints. A failure message and the guard's evidence both end up in a
+ /// run log and in a pull request body, this suite runs in public
+ /// repositories' builds, and a resource key is a credential that must
+ /// never be written down anywhere.
+ ///
+ public static string Redacted(string text)
+ => Redacted(text, Resource);
+
+ ///
+ /// The same, against a resource key given here rather than the
+ /// configured one. The configured one is read once when this class is
+ /// first touched, so a test cannot set it; it passes its own instead.
+ ///
+ internal static string Redacted(string text, string? resource)
+ => string.IsNullOrEmpty(resource)
+ ? text
+ : text.Replace(
+ resource, "", StringComparison.Ordinal);
+
+ ///
+ /// One line of the script either side of the match, with the resource
+ /// key taken out of it.
+ ///
+ /// The body this slices is the cloud-rendered client script, which
+ /// carries the resource key in the addresses it calls back on, so a
+ /// slice of it can contain the key wherever the match happens to fall.
+ /// The redaction is done here rather than at the call site because the
+ /// caller cannot tell from the returned text whether it needs it.
+ ///
+ ///
+ private static string Around(string body, int at)
+ => Around(body, at, Resource);
+
+ ///
+ /// The same, against a resource key given here rather than the
+ /// configured one, so the redaction can be tested.
+ ///
+ internal static string Around(string body, int at, string? resource)
+ {
+ var from = Math.Max(0, at - 40);
+ var to = Math.Min(body.Length, at + 120);
+ return Redacted(
+ body.Substring(from, to - from)
+ .Replace("\r", " ", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal),
+ resource);
+ }
+
+ #region Browsers
+
+ ///
+ /// Chrome, with both publisher site names pointed at this machine.
+ /// Nothing is added to the hosts file and nothing has to resolve on
+ /// the network, which is what makes the cross site case run on any
+ /// runner.
+ ///
+ public static IWebDriver NewChrome(
+ IReadOnlyDictionary? preferences = null,
+ params string[] extraArguments)
+ {
+ var options = new ChromeOptions();
+ options.AddArgument("--headless=new");
+ options.AddArgument("--no-sandbox");
+ options.AddArgument("--disable-dev-shm-usage");
+ options.AddArgument(
+ "--host-resolver-rules="
+ + $"MAP {SiteA} 127.0.0.1,MAP {SiteB} 127.0.0.1");
+ options.AcceptInsecureCertificates = true;
+ foreach (var argument in extraArguments)
+ {
+ options.AddArgument(argument);
+ }
+ if (preferences != null)
+ {
+ foreach (var preference in preferences)
+ {
+ options.AddUserProfilePreference(
+ preference.Key, preference.Value);
+ }
+ }
+ return Start(options);
+ }
+
+ ///
+ /// Firefox, with the same two site names pointed at this machine.
+ /// Firefox has no host resolver rules, so network.dns.localDomains is
+ /// used, which is the setting that does the same job.
+ ///
+ public static IWebDriver NewFirefox(
+ IReadOnlyDictionary? preferences = null)
+ {
+ var options = new FirefoxOptions { AcceptInsecureCertificates = true };
+ options.AddArgument("-headless");
+ options.SetPreference("network.dns.localDomains", $"{SiteA},{SiteB}");
+ // Third party cookies are what the shared choice travels on, so
+ // the tests state the behaviour they are written for rather than
+ // inheriting whatever the installed build defaults to. 0 is
+ // "accept all", and the pair below is Firefox's own total cookie
+ // protection, which would otherwise keep the cloud's cookie in a
+ // separate jar per site and hide a working exchange.
+ options.SetPreference("network.cookie.cookieBehavior", 0);
+ options.SetPreference(
+ "privacy.partition.network_state.ocsp_cache", false);
+ if (preferences != null)
+ {
+ foreach (var preference in preferences)
+ {
+ switch (preference.Value)
+ {
+ case bool flag:
+ options.SetPreference(preference.Key, flag);
+ break;
+ case int number:
+ options.SetPreference(preference.Key, number);
+ break;
+ default:
+ options.SetPreference(
+ preference.Key,
+ Convert.ToString(
+ preference.Value,
+ CultureInfo.InvariantCulture) ?? string.Empty);
+ break;
+ }
+ }
+ }
+ return Start(options);
+ }
+
+ ///
+ /// A local browser, or one on the Selenium grid SELENIUM_URL names,
+ /// which is how every browser in this suite is started. A grid has to
+ /// share this machine's network, because the site names resolve to
+ /// 127.0.0.1 inside the browser.
+ ///
+ private static IWebDriver Start(DriverOptions options)
+ {
+ if (ExternalSeleniumHelper.IsExternalSelenium(out var seleniumUrl))
+ {
+ ExternalSeleniumHelper.AddExternalSeleniumArguments(options);
+ return new RemoteWebDriver(new Uri(seleniumUrl), options);
+ }
+ return options switch
+ {
+ ChromeOptions chrome => new ChromeDriver(chrome),
+ FirefoxOptions firefox => new FirefoxDriver(firefox),
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(options),
+ $"{options.GetType().Name} is not a browser these tests use."),
+ };
+ }
+
+ ///
+ /// Waits for a condition the page reports, polling rather than
+ /// sleeping, so a test never asserts on a clock. The message is what
+ /// the failure says, so it names what never happened.
+ ///
+ public static void Until(
+ Func condition, string whatWasWaitedFor)
+ {
+ var deadline = DateTime.UtcNow + Patience;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (condition())
+ {
+ return;
+ }
+ Thread.Sleep(100);
+ }
+ Assert.Fail(Redacted(
+ $"Waited {Patience.TotalSeconds:0} seconds and "
+ + $"{whatWasWaitedFor} never happened."));
+ }
+
+ #endregion
+}
diff --git a/Browser51Did/IsGdprAcceptanceTests.cs b/Browser51Did/IsGdprAcceptanceTests.cs
new file mode 100644
index 0000000..2a298aa
--- /dev/null
+++ b/Browser51Did/IsGdprAcceptanceTests.cs
@@ -0,0 +1,115 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// Whether the regulation applies, and where PMP gets
+/// that from.
+///
+/// **These cannot pass until the change that puts IsGdpr in the cloud
+/// service is released**, being pipeline-dotnet pull request 413 and then
+/// a cloud service release that carries it. The harness entitlement
+/// record already asks for the property, so the moment the container
+/// carries it these run. Until then
+/// the first test reports inconclusive with that reason rather than
+/// failing, because a red test nobody can fix teaches a reader to ignore
+/// red tests.
+///
+///
+/// The dialog is shown and an identifier created either way, because the
+/// question PMP asks is the Model Terms usage, which is a matter
+/// of contract, and not a consent under the regulation.
+///
+///
+[TestClass, TestCategory("Browser51Did")]
+public class IsGdprAcceptanceTests : Browser51DidTestBase
+{
+ ///
+ /// With the property carrying a value, PMP's framework
+ /// surface reports it, and a false value does not stop the visitor
+ /// being asked.
+ ///
+ [Browser51DidTest]
+ public void IsGdpr_ReadFromTheClientScript_SetsGdprApplies()
+ {
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.Common);
+ visitor.WaitForPmp();
+ visitor.WaitForClientRounds(1);
+
+ var value = visitor.IsGdpr();
+ if (value == "")
+ {
+ Assert.Inconclusive(
+ "The client script's object carries no isgdpr value, so "
+ + "there is nothing for the PMP to read. The property "
+ + "reaches the cloud service in pipeline-dotnet pull "
+ + "request 413 and then a cloud service release that "
+ + "carries it, and the harness entitlement record already "
+ + "asks for it, so this runs as soon as the container "
+ + "carries it.");
+ }
+
+ var applies = visitor.GdprApplies();
+ Assert.AreEqual(
+ string.Equals(value, "True", StringComparison.OrdinalIgnoreCase),
+ applies,
+ "PMP's framework surface must report what the client "
+ + $"script resolved. The script said '{value}' and the surface "
+ + $"said {applies}.");
+
+ Assert.IsTrue(
+ visitor.CardVisible("preferences"),
+ "the visitor is asked whatever the answer is, because the "
+ + "question is the Model Terms usage and that is contractual "
+ + "rather than a consent under the regulation.");
+
+ Assert.AreEqual(
+ 0,
+ visitor.ConsoleMatching("isgdpr").Count(
+ line => line.Contains(
+ "WARNING", StringComparison.OrdinalIgnoreCase)),
+ "with a value to read there is nothing to warn about. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+ }
+
+ ///
+ /// With no value to read, PMP says so once and carries on as
+ /// though the regulation applies, which is the safe way round.
+ ///
+ [Browser51DidTest]
+ public void IsGdprAbsent_PmpWarnsAndAssumesItApplies()
+ {
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.Common);
+ visitor.WaitForPmp();
+ visitor.WaitForClientRounds(1);
+
+ if (visitor.IsGdpr() != "")
+ {
+ Assert.Inconclusive(
+ "The harness resource key does carry a value for isgdpr, "
+ + "so this is not the case under test. A key that does not "
+ + "ask for the property is needed, which the harness does "
+ + "not have a second of.");
+ }
+
+ Assert.IsTrue(
+ visitor.GdprApplies(),
+ "with nothing to read, PMP assumes the regulation "
+ + "applies rather than assuming it does not.");
+ Assert.IsTrue(
+ visitor.ConsoleMatching("isgdpr").Count > 0,
+ "PMP says once that it could not read the property, "
+ + "because a publisher whose key does not carry it has no other "
+ + "way of finding out. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ Assert.IsTrue(
+ visitor.CardVisible("preferences"),
+ "the dialog is still shown and an identifier still created.");
+ }
+}
diff --git a/Browser51Did/PmpAcceptanceTests.cs b/Browser51Did/PmpAcceptanceTests.cs
new file mode 100644
index 0000000..6564e37
--- /dev/null
+++ b/Browser51Did/PmpAcceptanceTests.cs
@@ -0,0 +1,517 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using FiftyOne.Did.Model;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// PMP and the client script on one page, which is the
+/// arrangement the design is built around, proved in a real browser.
+///
+/// These cover the shared choice read on a second site, the first visit
+/// that runs the whole sequence, and the two tags in either order, with
+/// the change of answer and the alternative answer alongside them. Every
+/// assertion is on an ordering of requests and page states, never on a
+/// clock.
+///
+///
+[TestClass, TestCategory("Browser51Did")]
+public class PmpAcceptanceTests : Browser51DidTestBase
+{
+ ///
+ /// The test that carries the most. A first visit,
+ /// the visitor answers the first card, the answer reaches the client
+ /// script, the full sequence runs with the usage known and an
+ /// identifier comes back with the signal source recorded as direct.
+ /// The second card is up before any of that finished, because nothing
+ /// waits between the two cards.
+ ///
+ [Browser51DidTest]
+ public void CommonPath_PmpThenScript_Chrome()
+ => CommonPath(Chrome, pmpFirst: true);
+
+ ///
+ /// The same, in the other browser. Both are run because anything the
+ /// shared choice travels on behaves differently between them.
+ ///
+ [Browser51DidTest]
+ public void CommonPath_PmpThenScript_Firefox()
+ => CommonPath(Firefox, pmpFirst: true);
+
+ ///
+ /// The same page with the two tags the other way round. PMP's
+ /// bundle loads on its own timetable, so the announcement has to reach
+ /// the client script whichever tag the publisher wrote first, and a
+ /// design that only worked one way round would pass the test above and
+ /// fail on half the customers' pages.
+ ///
+ [Browser51DidTest]
+ public void CommonPath_ScriptThenPmp_Chrome()
+ => CommonPath(Chrome, pmpFirst: false);
+
+ private void CommonPath(string browser, bool pmpFirst)
+ {
+ RequireMarketingIdentifiers();
+ using var visitor = NewVisitor(browser);
+ visitor.Go(
+ Harness.SiteA,
+ pmpFirst ? Routes.Common : Routes.CommonScriptFirst);
+
+ // The first round. Nobody has been asked yet, so nothing may be
+ // created, which is the rule the whole programme exists for.
+ visitor.WaitForClientRounds(1);
+ Assert.IsTrue(
+ visitor.HasTheNewClientScript(),
+ "the page loaded a client script with no user prompt block in "
+ + "it, so this test would be proving the old behaviour. The "
+ + "guard passed, so the container served the new script and "
+ + "something else on the page loaded an old one.");
+ var first = visitor.ClientRequests()[0];
+ Assert.IsNull(
+ first.Form("id.usage"),
+ "the first request went before anyone was asked, so it must "
+ + $"carry no answer. It carried: {first.Body}");
+ Assert.AreEqual(
+ "",
+ visitor.Identifier(),
+ "no answer means no identifier, however much else the cloud "
+ + "resolved.");
+
+ // The visitor answers.
+ visitor.WaitForPmp();
+ visitor.WaitForCard("preferences");
+ // Records when each round finishes and when the share card arrives,
+ // for the failure message below. It changes nothing on the page.
+ visitor.StartTimeline();
+ var roundsBefore = visitor.ClientRequests().Count;
+ visitor.Press("standard");
+
+ // The second card is up before the refresh has finished. How many
+ // rounds had finished is counted inside the page, once at the moment
+ // of the press and once at the first frame the card is visible, so
+ // no round can finish unseen in the time it takes to ask the page
+ // from here. The page may well have finished more than one round
+ // before the press, because the snippets it was asked to run produce
+ // a round of their own, so the count at the press is read rather
+ // than assumed to be one.
+ Harness.Until(
+ () => visitor.RoundsFinishedAround().AtShareVisible >= 0,
+ $"the share card appeared in {browser}. The console said: "
+ + $"{string.Join(" | ", visitor.Console())}. What the page "
+ + $"recorded, frame by frame, was: {visitor.Timeline()}");
+ var (doneAtPress, doneAtShare) = visitor.RoundsFinishedAround();
+ Assert.AreEqual(
+ doneAtPress,
+ doneAtShare,
+ "the share card must follow the first card at once, with "
+ + "nothing waiting on the refresh. When it first became visible "
+ + $"the client script had finished {doneAtShare} rounds rather "
+ + $"than the {doneAtPress} it had finished at the press, so "
+ + "something waited. What the page recorded, frame by frame, "
+ + $"was: {visitor.Timeline()}");
+
+ // Exactly one further request, carrying the answer once and every
+ // snippet result the page had worked out.
+ visitor.WaitForClientRounds(roundsBefore + 1);
+ var requests = visitor.ClientRequests();
+ Assert.AreEqual(
+ roundsBefore + 1,
+ requests.Count,
+ "answering the first card must produce exactly one further "
+ + "request. The scripts on the page were: "
+ + string.Join(", ", visitor.ScriptSources())
+ + ". The console said: "
+ + string.Join(" | ", visitor.Console())
+ + ". The requests were: "
+ + string.Join(" || ", requests.Select(r => r.ToString())));
+ var answered = requests[requests.Count - 1];
+ Assert.AreEqual(
+ "standard",
+ answered.Form("id.usage"),
+ $"the answer must reach the cloud. The body was: {answered.Body}");
+ Assert.AreEqual(
+ 1,
+ answered.FormCount("id.usage"),
+ "the answer must be sent once. The server takes the first value "
+ + "of a repeated key and warns, so sending it twice would "
+ + $"create nothing. The body was: {answered.Body}");
+ foreach (var snippet in visitor.SnippetValueNames())
+ {
+ Assert.IsNotNull(
+ answered.Form(snippet),
+ $"the request that creates the identifier must carry every "
+ + $"snippet result. '{snippet}' was stored by the page and "
+ + $"not sent. The body was: {answered.Body}");
+ }
+
+ // The identifier, and the flag that says the answer was stated
+ // rather than decoded from a framework string.
+ var identifier = visitor.Identifier();
+ AssertDirect(
+ identifier,
+ "an answer given on PMP is stated directly");
+ Assert.AreEqual(
+ Usage.Standard,
+ UsageOf(identifier, "the answer was standard"),
+ "the visitor pressed standard.");
+ Assert.IsTrue(
+ answered.Response.Contains("fodid", StringComparison.Ordinal),
+ "the response to the answered request must carry the identifier "
+ + $"section. It was: {Truncate(answered.Response)}");
+
+ // Nothing loaded a second copy of the client script.
+ Assert.AreEqual(
+ 0,
+ visitor.ConsoleMatching(Harness.SecondInstanceWarning).Count,
+ "a second instance of the client script was loaded onto the "
+ + "page. The console said: "
+ + string.Join(" | ", visitor.Console()));
+
+ // And the shared write, which is the second card's whole purpose.
+ RequireSharing();
+ visitor.Press("share-accept");
+ Harness.Until(
+ () => visitor.SharedStoreRequests()
+ .Any(r => r.Method == "POST" && r.Done),
+ $"the shared choice was written in {browser}. The console said: "
+ + $"{string.Join(" | ", visitor.Console())}");
+ var write = visitor.SharedStoreRequests()
+ .Last(r => r.Method == "POST");
+ Assert.AreEqual(
+ 200,
+ write.Status,
+ "the cloud must accept the shared write. It answered "
+ + $"{write.Status}: {Truncate(write.Response)}");
+ }
+
+ ///
+ /// A choice made on one site is read on another, the
+ /// visitor is not asked again, and the client script still creates an
+ /// identifier from the answer with the signal source recorded as
+ /// direct.
+ ///
+ [Browser51DidTest]
+ public void SharedChoice_SecondSite_NoDialogAndDirectFlag_Chrome()
+ => SharedChoice(Chrome);
+
+ ///
+ /// The same across browsers, because the shared choice travels on a
+ /// third party cookie and that is the thing the two browsers treat
+ /// differently.
+ ///
+ [Browser51DidTest]
+ public void SharedChoice_SecondSite_NoDialogAndDirectFlag_Firefox()
+ => SharedChoice(Firefox);
+
+ private void SharedChoice(string browser)
+ {
+ Harness.RequireSecureCookies();
+ RequireMarketingIdentifiers();
+ RequireSharing();
+ using var visitor = NewVisitor(browser);
+
+ // Site A, where the visitor answers and agrees to share.
+ visitor.Go(Harness.SiteA, Routes.Common);
+ visitor.WaitForCard("preferences");
+ visitor.Press("standard");
+ visitor.WaitForCard("share");
+ visitor.Press("share-accept");
+ Harness.Until(
+ () => visitor.SharedStoreRequests()
+ .Any(r => r.Method == "POST" && r.Done && r.Status == 200),
+ $"the choice was shared from {Harness.SiteA} in {browser}. The "
+ + $"console said: {string.Join(" | ", visitor.Console())}");
+
+ // Site B, a first visit, in the same browser.
+ visitor.Go(Harness.SiteB, Routes.Common);
+ visitor.WaitForPmp();
+ visitor.WaitForClientRounds(1);
+
+ var read = visitor.SharedStoreRequests()
+ .FirstOrDefault(r => r.Method == "GET" && r.Done);
+ Assert.IsNotNull(
+ read,
+ "PMP must ask the shared store what this visitor "
+ + "already chose. It made no such call. The requests were: "
+ + string.Join(" || ",
+ visitor.Requests().Select(r => r.ToString())));
+ Assert.IsTrue(
+ read!.Response.Contains("standard", StringComparison.Ordinal),
+ "the shared store must answer with the choice made on the other "
+ + $"site. It answered: {Truncate(read.Response)}");
+
+ Assert.IsTrue(
+ visitor.BubbleOnly(),
+ "a visitor who has already answered is not asked again, so only "
+ + $"the floating button shows. The dialog is: {visitor.PmpState()}"
+ + ". The console said: "
+ + string.Join(" | ", visitor.Console()));
+
+ Harness.Until(
+ () => visitor.ClientRequests()
+ .Any(r => r.Done && r.Form("id.usage") == "standard"),
+ "the answer read from the shared store reached the client "
+ + $"script in {browser}. The console said: "
+ + $"{string.Join(" | ", visitor.Console())}");
+ var answered = visitor.ClientRequests()
+ .Last(r => r.Form("id.usage") == "standard");
+ Assert.AreEqual(
+ 1,
+ answered.FormCount("id.usage"),
+ "the answer must be sent once, or the server drops the repeat "
+ + $"and creates nothing. The body was: {answered.Body}");
+
+ var identifier = visitor.Identifier();
+ AssertDirect(
+ identifier,
+ "a choice made on PMP stays a stated usage on the "
+ + "second site");
+ Assert.AreEqual(
+ Usage.Standard,
+ UsageOf(identifier, "the choice was standard"),
+ "the choice carried across is the one that was made.");
+
+ Assert.AreEqual(
+ "standard",
+ visitor.PmpPreference(),
+ "PMP's own getter must answer with the choice it is "
+ + "acting on, on the second site as much as on the first.");
+
+ var stored = visitor.LocalStorageKeys();
+ Assert.AreEqual(
+ 0,
+ stored.Count,
+ "nothing new is written to browser storage by PMP, and "
+ + "the second site's answer lives in the shared store rather "
+ + "than being copied here. It wrote: "
+ + string.Join(", ", stored));
+ }
+
+ ///
+ /// A change of answer on the same page. The sequence goes up, a
+ /// different identifier comes back, and page code that registered a
+ /// change handler is told.
+ ///
+ [Browser51DidTest]
+ public void ChangeOfAnswer_SamePage_NewIdentifierAndOnChange()
+ {
+ RequireMarketingIdentifiers();
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.Change);
+ visitor.WaitForCard("preferences");
+ visitor.Press("standard");
+ Harness.Until(
+ () => visitor.Identifier() != "",
+ "the first answer produced an identifier. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var firstIdentifier = visitor.Identifier();
+ var roundsBefore = visitor.ClientRequests().Count;
+ var sequenceBefore = Sequence(visitor.ClientRequests().Last());
+
+ // The visitor changes their mind, through PMP.
+ visitor.OpenPmp();
+ visitor.WaitForCard("preferences");
+ visitor.Press("personalized");
+
+ Harness.Until(
+ () => visitor.Identifier() != ""
+ && visitor.Identifier() != firstIdentifier,
+ "a different identifier came back for the changed answer. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+ var requests = visitor.ClientRequests();
+ Assert.AreEqual(
+ roundsBefore + 1,
+ requests.Count,
+ "a change of answer makes exactly one further request. The "
+ + "scripts on the page were: "
+ + string.Join(", ", visitor.ScriptSources())
+ + ". The console said: "
+ + string.Join(" | ", visitor.Console())
+ + ". The requests were: "
+ + string.Join(" || ", requests.Select(r => r.ToString())));
+ var changed = requests[requests.Count - 1];
+ Assert.AreEqual(
+ "personalized",
+ changed.Form("id.usage"),
+ $"the new answer must be the one sent. The body was: {changed.Body}");
+ Assert.AreEqual(
+ sequenceBefore + 1,
+ Sequence(changed),
+ "the sequence goes up by one for the round that carried the "
+ + $"changed answer. The body was: {changed.Body}");
+
+ var second = visitor.Identifier();
+ AssertDirect(second, "the changed answer was stated directly");
+ Assert.AreEqual(
+ Usage.Personalized,
+ UsageOf(second, "the changed answer was personalized"),
+ "the identifier must carry the answer that was actually given.");
+ Assert.AreNotEqual(
+ firstIdentifier,
+ second,
+ "a change of answer produces a new identifier.");
+
+ var told = visitor.ChangeIdentifiers();
+ Assert.IsTrue(
+ told.Contains(second),
+ "page code that registered a change handler before the change "
+ + "must be told about the new identifier. It was told: "
+ + string.Join(", ", told));
+ }
+
+ ///
+ /// A change of answer carried across two pages in one tab. The second
+ /// page must never serve the visitor the answer they moved away from,
+ /// which is the whole point of the record the client script keeps.
+ ///
+ /// What is asserted is the outcome and not the number of requests. The
+ /// stored record is the inputs of the last request, and a page view
+ /// whose inputs match it is meant to reuse the answer rather than ask
+ /// again, which is the rule about reuse working rather than failing.
+ /// Changing the answer on the first page makes that page ask again and
+ /// re-record, so the second page's inputs match the new record and it
+ /// may legitimately reuse it. Measured against a running service on
+ /// 15 September 2026, that is what happens. Asserting a fresh request
+ /// here would be asserting that the cache does not work.
+ ///
+ ///
+ [Browser51DidTest]
+ public void ChangeOfAnswer_AcrossPages_TheSecondPageCarriesTheNewAnswer()
+ {
+ RequireMarketingIdentifiers();
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.TwoOne);
+ visitor.WaitForCard("preferences");
+ visitor.Press("standard");
+ Harness.Until(
+ () => visitor.Identifier() != "",
+ "the first page produced an identifier. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var underStandard = visitor.Identifier();
+ Assert.AreEqual(
+ Usage.Standard,
+ UsageOf(underStandard, "the first answer"),
+ "the visitor pressed standard first.");
+
+ // The answer is changed before the visitor leaves the first page.
+ visitor.OpenPmp();
+ visitor.WaitForCard("preferences");
+ visitor.Press("personalized");
+ Harness.Until(
+ () => visitor.Identifier() != underStandard
+ && visitor.Identifier() != "",
+ "the changed answer took effect on the first page. The console "
+ + "said: " + string.Join(" | ", visitor.Console()));
+ Assert.AreEqual(
+ Usage.Personalized,
+ UsageOf(visitor.Identifier(), "the changed answer"),
+ "the change was to personalized.");
+
+ // The second page, in the same tab.
+ visitor.Go(Harness.SiteA, Routes.TwoTwo);
+ Harness.Until(
+ () => visitor.Identifier() != "",
+ "the second page settled on an identifier. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var onPageTwo = visitor.Identifier();
+
+ Assert.AreEqual(
+ Usage.Personalized,
+ UsageOf(onPageTwo, "the second page's identifier"),
+ "the answer in force when the second page loaded is the one it "
+ + "must carry, whether it asked again or reused the record "
+ + "written after the change.");
+ Assert.AreNotEqual(
+ underStandard,
+ onPageTwo,
+ "the second page must never be serving the identifier made "
+ + "under the answer the visitor moved away from.");
+
+ var told = visitor.ChangeIdentifiers();
+ Assert.IsTrue(
+ told.Contains(onPageTwo),
+ "the change handler on the new instance must be told about the "
+ + "identifier it settled on, including where that came from the "
+ + "record rather than from a fresh request. It was told: "
+ + string.Join(", ", told));
+ }
+
+ ///
+ /// The alternative answer. It is an answer under the Model Terms like
+ /// any other, so it creates an identifier with the direct flag, and
+ /// there is nothing to share, so no second card is offered.
+ ///
+ [Browser51DidTest]
+ public void AlternativeAnswer_CreatesNonMarketingAndFiresTheAction()
+ {
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.Common);
+ visitor.WaitForCard("preferences");
+ visitor.Press("alternative");
+
+ Harness.Until(
+ visitor.AlternativeFired,
+ "the action the publisher configured for the alternative "
+ + "button fired. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ Harness.Until(
+ () => visitor.CardVisible("preferences") == false,
+ "the dialog closed after the alternative was pressed. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+ Assert.IsFalse(
+ visitor.CardVisible("share"),
+ "there is nothing to share, so the second card must never be "
+ + "offered after the alternative.");
+
+ Harness.Until(
+ () => visitor.ClientRequests()
+ .Any(r => r.Done && r.Form("id.usage") == "non-marketing"),
+ "the alternative answer reached the cloud. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var answered = visitor.ClientRequests()
+ .Last(r => r.Form("id.usage") == "non-marketing");
+ Assert.AreEqual(
+ 1,
+ answered.FormCount("id.usage"),
+ $"the answer must be sent once. The body was: {answered.Body}");
+
+ Harness.Until(
+ () => visitor.Identifier() != "",
+ "an identifier came back for the alternative answer. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+ var identifier = visitor.Identifier();
+ AssertDirect(
+ identifier,
+ "the alternative is an answer the visitor gave directly");
+ Assert.AreEqual(
+ Usage.NonMarketing,
+ UsageOf(identifier, "the alternative answer"),
+ "the alternative stores non-marketing.");
+
+ // The framework surface answers (null, false) after the
+ // alternative, which is the framework's view of a usage granting
+ // no purposes. It is observed here so that the difference between
+ // it and what the request carried is on the record.
+ Assert.AreEqual(
+ "non-marketing",
+ visitor.PmpPreference(),
+ "PMP is holding an answer even though its framework "
+ + "surface reports none, and the answer is what creates the "
+ + "identifier.");
+ }
+
+ private static int Sequence(RecordedRequest request)
+ => int.TryParse(
+ request.Form("sequence"),
+ System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var value)
+ ? value
+ : 0;
+
+ private static string Truncate(string value)
+ => value.Length <= 300 ? value : value.Substring(0, 300) + "...";
+}
diff --git a/Browser51Did/PmpAddsClientScriptTests.cs b/Browser51Did/PmpAddsClientScriptTests.cs
new file mode 100644
index 0000000..1e8fd6c
--- /dev/null
+++ b/Browser51Did/PmpAddsClientScriptTests.cs
@@ -0,0 +1,216 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using FiftyOne.Did.Model;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// A page carrying PMP's tag and no client script tag
+/// at all.
+///
+/// PMP has one route to the third party cookie result and to
+/// whether the regulation applies, which is the client script's object.
+/// Where the page has no client script tag PMP adds the script
+/// itself, using the cloud that served it and the
+/// resource key it already holds, and says in the console that it did. A
+/// second route would be a second answer to the same question, and a
+/// publisher who forgot the tag would get a dialog behaving differently
+/// from the documented one with nothing to tell them why.
+///
+///
+[TestClass, TestCategory("Browser51Did")]
+public class PmpAddsClientScriptTests : Browser51DidTestBase
+{
+ ///
+ /// The whole of it in one page view, from the script arriving to the
+ /// identifier coming back.
+ ///
+ [Browser51DidTest]
+ public void NoClientScriptTag_PmpAddsItAndTheAnswerStillCreates()
+ {
+ RequireMarketingIdentifiers();
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.PlatformOnly);
+ visitor.WaitForPmp();
+
+ // The script PMP added, named by where it came from and
+ // which publisher it is for.
+ Harness.Until(
+ () => AddedClientScript(visitor) != null,
+ "PMP added the client script. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var added = AddedClientScript(visitor)!;
+ Assert.IsTrue(
+ added.StartsWith(Harness.CloudUrl!, StringComparison.OrdinalIgnoreCase),
+ "the script must come from the cloud that served PMP, "
+ + "because that is the only cloud PMP knows about. It "
+ + $"came from {added}.");
+ Assert.IsTrue(
+ added.Contains(Harness.Resource!, StringComparison.Ordinal),
+ "the script must be asked for with the resource key PMP "
+ + "already holds, or the cloud has no idea which "
+ + $"publisher is asking. The URL was {added}.");
+
+ // And it said so, which is the point of the convenience.
+ Assert.IsTrue(
+ visitor.ConsoleMatching(Harness.NoClientScriptMessage).Count > 0,
+ "PMP must say in the console that there was no client "
+ + "script object on the page, because a publisher who left the "
+ + "tag out has no other way of finding out. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ Assert.IsTrue(
+ visitor.ConsoleMatching(Harness.AddingClientScriptMessage).Count > 0,
+ "and that it is adding one, so the publisher knows where the "
+ + "extra request came from. The console said: "
+ + string.Join(" | ", visitor.Console()));
+
+ // The object then exists, under the name in force.
+ visitor.WaitForClientObject();
+ Assert.IsTrue(
+ visitor.HasTheNewClientScript(),
+ "the script PMP added must be the new one, or PMP "
+ + "has quietly given itself the old behaviour.");
+
+ // The third party cookie result reached PMP through it,
+ // which is the reason the script is added at all.
+ Harness.Until(
+ () => visitor.ThirdPartyCookies() != "",
+ "the client script resolved the third party cookie result. The "
+ + "console said: " + string.Join(" | ", visitor.Console()));
+
+ // And the answer still creates, exactly as it would have on a page
+ // that carried the tag.
+ visitor.WaitForCard("preferences");
+ visitor.Press("standard");
+ Harness.Until(
+ () => visitor.ClientRequests()
+ .Any(r => r.Done && r.Form("id.usage") == "standard"),
+ "the answer reached the cloud through the script PMP "
+ + "added. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var answered = visitor.ClientRequests()
+ .Last(r => r.Form("id.usage") == "standard");
+ Assert.AreEqual(
+ 1,
+ answered.FormCount("id.usage"),
+ $"the answer must be sent once. The body was: {answered.Body}");
+ foreach (var snippet in visitor.SnippetValueNames())
+ {
+ Assert.IsNotNull(
+ answered.Form(snippet),
+ "the request that creates the identifier must carry every "
+ + $"snippet result. '{snippet}' was stored and not sent. "
+ + $"The body was: {answered.Body}");
+ }
+
+ Harness.Until(
+ () => visitor.Identifier() != "",
+ "an identifier came back. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ AssertDirect(
+ visitor.Identifier(),
+ "an answer given on PMP is stated directly");
+ Assert.AreEqual(
+ Usage.Standard,
+ UsageOf(visitor.Identifier(), "the answer"),
+ "the visitor pressed standard.");
+
+ // One script, not two. PMP adds one only where there is
+ // none, so nothing here may trip the warning about a second copy.
+ Assert.AreEqual(
+ 0,
+ visitor.ConsoleMatching(Harness.SecondInstanceWarning).Count,
+ "PMP added a second copy of the client script, or "
+ + "added one to a page that already had it. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ Assert.AreEqual(
+ 1,
+ ClientScriptsOnThePage(visitor),
+ "there must be exactly one client script on the page. Its "
+ + "sources were: " + string.Join(", ", visitor.ScriptSources()));
+ }
+
+ ///
+ /// The publisher may name the object something other than the default,
+ /// and PMP then asks for the script under that name, finds it
+ /// under that name, and says which name it used.
+ ///
+ [Browser51DidTest]
+ public void ObjectNameAttribute_NamesTheObjectEverywhere()
+ {
+ const string objectName = Harness.NamedObject;
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.NamedObject);
+ visitor.WaitForPmp();
+
+ Harness.Until(
+ () => AddedClientScript(visitor) != null,
+ "PMP added the client script. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ var added = AddedClientScript(visitor)!;
+ Assert.IsTrue(
+ added.Contains(objectName, StringComparison.Ordinal),
+ "the name the publisher asked for must be on the URL, or the "
+ + "cloud renders the script under the default name and PMP "
+ + $"then looks for the wrong object. The URL was {added}.");
+
+ visitor.WaitForClientObject(objectName);
+ Assert.IsTrue(
+ visitor.HasTheNewClientScript(objectName),
+ $"the object under '{objectName}' must be the client script's.");
+ Assert.IsFalse(
+ visitor.HasClientObject(Harness.DefaultObjectName),
+ "nothing may be put under the default name when the publisher "
+ + "asked for another one, because two objects would be two "
+ + "instances and the page would warn about the second.");
+
+ var named = visitor.ConsoleMatching(objectName);
+ Assert.IsTrue(
+ named.Count > 0,
+ "every message about the object names whichever name is in "
+ + "force, so a publisher reading the console can tell which "
+ + "object is being talked about. The console said: "
+ + string.Join(" | ", visitor.Console()));
+ }
+
+ ///
+ /// The attribute left off uses the default name, which is what the
+ /// documentation says and what an existing page relies on.
+ ///
+ [Browser51DidTest]
+ public void ObjectNameAttributeAbsent_UsesTheDefaultName()
+ {
+ using var visitor = NewVisitor(Chrome);
+ visitor.Go(Harness.SiteA, Routes.PlatformOnly);
+ visitor.WaitForPmp();
+ visitor.WaitForClientObject(Harness.DefaultObjectName);
+ Assert.IsTrue(
+ visitor.HasTheNewClientScript(Harness.DefaultObjectName),
+ "with no name asked for, the object is the default one.");
+ }
+
+ ///
+ /// The client script PMP added, or null where it has not
+ /// added one yet. Anything the cloud serves as a resource key script
+ /// counts, and the page carried none of its own, so whatever is there
+ /// was added.
+ ///
+ private static string? AddedClientScript(Visitor visitor)
+ => visitor.ScriptSources()
+ .FirstOrDefault(src =>
+ src.Contains("/api/v4/", StringComparison.OrdinalIgnoreCase)
+ && src.Contains(".js", StringComparison.OrdinalIgnoreCase)
+ && src.Contains("/pmp", StringComparison.OrdinalIgnoreCase)
+ == false);
+
+ private static int ClientScriptsOnThePage(Visitor visitor)
+ => visitor.ScriptSources()
+ .Count(src =>
+ src.Contains("/api/v4/", StringComparison.OrdinalIgnoreCase)
+ && src.Contains(".js", StringComparison.OrdinalIgnoreCase)
+ && src.Contains("/pmp", StringComparison.OrdinalIgnoreCase)
+ == false);
+}
diff --git a/Browser51Did/README.md b/Browser51Did/README.md
new file mode 100644
index 0000000..7245f65
--- /dev/null
+++ b/Browser51Did/README.md
@@ -0,0 +1,134 @@
+# 51Did browser acceptance tests
+
+Category `Browser51Did`. Fifteen tests proving, in real Chrome and Firefox,
+that a 51Did is created only after every other piece of data is complete and
+only from an answer the visitor actually gave, with the 51Degrees Preference
+Management Platform (PMP) and the client script on a publisher's page.
+
+Nine more need no cloud, no demo and no browser, so they run whenever the
+category does. Six, in [RedactionTests.cs](RedactionTests.cs), prove that the
+resource key cannot reach a test report, and three, in
+[DemoSettingsTests.cs](DemoSettingsTests.cs), which of its two names the key
+is read from.
+
+They drive a **demo**, a small web app that serves every page the tests
+load. The dotnet demo is `Examples/Cloud/pmp-web` in
+[device-detection-dotnet-examples](https://github.com/51Degrees/device-detection-dotnet-examples).
+The pages, the recorder that watches them, the change watcher and the stub
+consent platform are plain static files in that demo, so a demo in another
+language copies them, fills the same placeholders, and these same tests then
+prove it behaves the same.
+
+## What they prove
+
+1. A choice made on one site is read on another, the visitor is not asked
+ again, and a 51Did is still created with the signal source recorded as
+ direct. Chrome and Firefox, because the choice travels on a third party
+ cookie.
+2. A page with a consent platform and no PMP sends the framework string,
+ the cloud decodes it, and the 51Did records that the usage was decoded.
+3. The common path. The first request carries no answer and creates
+ nothing, the visitor answers, the second card follows at once, exactly
+ one further request carries the answer and every snippet result, and the
+ 51Did comes back. Both tag orders.
+4. A change of answer on one page makes a new 51Did with the sequence one
+ higher and tells a change handler.
+5. A change of answer carried to the next page in the same tab.
+6. The alternative answer creates a `non-marketing` 51Did with the direct
+ flag, fires the publisher's action, and offers no second card.
+7. Whether the regulation applies, read from the client script. This cannot
+ pass until the IsGdpr property is released in the cloud, and reports
+ inconclusive with that reason until then.
+8. A page with nowhere to get an answer from creates nothing, says so once,
+ and stores a record carrying no answer.
+9. A page carrying the PMP and no client script tag gets the script added by
+ the PMP from the cloud that served it.
+10. `data-object-name` names the object everywhere, and its absence gives
+ the default `fod`.
+
+Every assertion is on an ordering of requests and page states, never on a
+clock. The guard, `Harness.RequireTheNewClientScript`, fails every test in a
+class unless the client script the pages load carries the new template's
+user prompt block.
+
+## The pages
+
+| Route | Markup | Tests |
+| --- | --- | --- |
+| `common` | PMP, then client script | common path, shared choice (as `site-a.localtest` then `site-b.localtest`), alternative, both IsGdpr |
+| `common-script-first` | client script, then PMP | common path, other tag order |
+| `change` | PMP, client script, change watcher | change of answer on one page |
+| `two/one`, `two/two` | the same as `change` | change of answer across pages |
+| `consent` | stub consent platform, then client script | consent platform only |
+| `no-platform` | client script alone | no PMP at all |
+| `platform-only` | PMP alone | PMP adds the client script, default object name |
+| `named-object` | PMP with `data-object-name="fiftyOneData"` | object name attribute |
+
+Each route is served under a mode prefix. `/cloud/` pages load the client
+script and the PMP straight from the cloud, and the client script posts to
+the cloud's `/api/v4/json`. `/pipeline/` pages have the demo's own pipeline
+serve the client script, which posts to the path the demo's descriptor names
+in `ExampleDescriptor.JsonEndpointPath`. The tests read that path from the
+mode rather than assuming one.
+
+## Running them
+
+The demo is started once for the run through `Examples/ExampleApps.cs`, as
+the Contract tests start an example.
+
+| Variable | Meaning |
+| --- | --- |
+| `51DEGREES_CLOUD_ENDPOINT` | The cloud, including its `api/v4` path. Handed to the demo unchanged. |
+| `51DEGREES_RESOURCE_KEY` | A resource key the cloud creates 51Dids for standard and personalized answers with. Handed to the demo under this name, and never printed, because every message a test produces has it taken out. |
+| `_51DEGREES_RESOURCE_KEY_51DID` | Read only where `51DEGREES_RESOURCE_KEY` is unset, and handed to the demo as `51DEGREES_RESOURCE_KEY`. This is the name continuous integration sets, for a resource key carrying the 51Did product. |
+| `DEMO_LANG` | The demo to launch from the sibling checkout. `dotnet` unless set. |
+| `DEMO_URL` | A demo already running, which is used instead of launching one. |
+| `DEMO_MODE` | `cloud` unless set to `pipeline`. |
+| `CLOUD_ROOT_URL` | Not used by these tests, but the suite's assembly set up requires it. |
+
+```bash
+env CLOUD_ROOT_URL="http://localhost:5050/" \
+ 51DEGREES_CLOUD_ENDPOINT="http://localhost:5050/api/v4/" \
+ 51DEGREES_RESOURCE_KEY="" \
+ dotnet test --filter TestCategory=Browser51Did
+```
+
+`env` is used because bash cannot export a variable whose name starts with a
+digit, which is also why the name continuous integration sets starts with an
+underscore. In PowerShell write `${env:51DEGREES_RESOURCE_KEY} = '...'`.
+
+With the endpoint or both resource key names unset every test that needs a
+browser reports inconclusive with the reason, and the nine that need none
+still run. A resource key that cannot create reports inconclusive with the
+cloud's own refusal, and so does a cloud that will not hold a shared choice.
+The shared choice needs the browser to keep the cloud's `Secure`
+`SameSite=None` cookie, which both browsers did from plain HTTP on
+`localhost` when measured, so it skips only for a plain HTTP cloud reached by
+another name.
+
+## Adding a demo in another language
+
+1. Copy the demo's static files and templates, and fill the same
+ placeholders from the same two variables.
+2. Serve every route under `/cloud/`, and under `/pipeline/` once the
+ language's own web integration serves the client script, answering any
+ host name, because the tests load the pages as `site-a.localtest` and
+ `site-b.localtest` on the demo's port.
+3. Add an entry to `ExampleApps.Demos` saying how to launch it.
+4. Run the category with `DEMO_LANG` set to that entry.
+
+## What the pipeline mode proves today
+
+The dotnet demo serves every route under `/pipeline/` as well, and
+`DEMO_MODE=pipeline` drives those pages. Their client script comes from the
+demo's own pipeline, which renders the user prompt block only with the
+pipeline packages that carry the new template, so with the released
+packages those pages show no prompt block and the tests that press it fail
+there. Two things differ in that mode. The PMP recognises a client script
+tag only by the cloud's path, `/api/v4/.js`, so the demo's pipeline
+pages carry a tag that is not `async`, which has always run before the PMP
+looks for its object. And on a page with no client script tag the PMP adds
+the cloud's script, which posts to the cloud's `/api/v4/json`, whilst
+`Visitor.ClientRequests` looks on the pipeline's path, so
+`NoClientScriptTag_PmpAddsItAndTheAnswerStillCreates` fails in that
+mode until the suite looks for the added script's own requests.
diff --git a/Browser51Did/RedactionTests.cs b/Browser51Did/RedactionTests.cs
new file mode 100644
index 0000000..9464273
--- /dev/null
+++ b/Browser51Did/RedactionTests.cs
@@ -0,0 +1,237 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// That a resource key cannot reach a test report. These tests need no
+/// cloud, no demo and no browser, because what they check is the suite's
+/// own handling of a result, so they run wherever the category runs.
+///
+/// They exist because the redaction was once done by editing the result
+/// MSTest had already built. That looked right and was not: assigning
+/// a second time does not
+/// replace the first exception, it aggregates with it, and the first one's
+/// message has already been copied into a member of MSTest's own that this
+/// assembly cannot set. The runner therefore reported the key and the
+/// redaction of it side by side. The fix is to report a result built here
+/// instead, which has neither. These tests fail against the edit-in-place
+/// version and pass against the copy.
+///
+///
+[TestClass, TestCategory("Browser51Did")]
+public class RedactionTests
+{
+ ///
+ /// A resource key that is not one, long enough that it cannot appear
+ /// in a message by accident.
+ ///
+ private const string Key = "NOTAREALRESOURCEKEY0123456789";
+
+ ///
+ /// Text of the shape a failure here really takes: the client script's
+ /// address, which names the resource key.
+ ///
+ private const string Mentions =
+ "the script at https://cloud.example/" + Key + ".js was wrong";
+
+ ///
+ /// A result with the key in every piece of text it holds, standing in
+ /// for the one MSTest hands the attribute after a test has failed.
+ /// Every piece is set by reflection rather than by name, so a member
+ /// added by a later MSTest is covered without this test being changed.
+ ///
+ private static TestResult ResultMentioningTheKeyThroughout()
+ {
+ var result = new TestResult
+ {
+ Outcome = UnitTestOutcome.Failed,
+ TestFailureException = new AssertFailedException(Mentions),
+ };
+ foreach (var field in TextFields())
+ {
+ field.SetValue(result, Mentions);
+ }
+ return result;
+ }
+
+ private static IEnumerable TextFields()
+ {
+ foreach (var field in typeof(TestResult).GetFields(
+ BindingFlags.Instance
+ | BindingFlags.Public
+ | BindingFlags.NonPublic))
+ {
+ if (field.FieldType == typeof(string))
+ {
+ yield return field;
+ }
+ }
+ }
+
+ ///
+ /// The one that would have caught the bug. Nothing the runner can read
+ /// off the result may carry the key, including the members the public
+ /// interface does not name.
+ ///
+ [TestMethod]
+ public void NoTextOnTheReportedResultCarriesTheKey()
+ {
+ var reported = Browser51DidTestAttribute.Redacted(
+ ResultMentioningTheKeyThroughout(), Key);
+ foreach (var field in TextFields())
+ {
+ var text = (string?)field.GetValue(reported);
+ Assert.IsFalse(
+ text != null
+ && text.Contains(Key, StringComparison.Ordinal),
+ $"TestResult.{field.Name} still carries the resource key "
+ + "after redaction, so a failing test would print it into "
+ + "a public build log. Anything the result holds has to be "
+ + "set from the redacted text or left unset.");
+ }
+ Assert.IsFalse(
+ reported.TestFailureException!.ToString()
+ .Contains(Key, StringComparison.Ordinal),
+ "the failure the runner reports still carries the resource "
+ + "key.");
+ }
+
+ ///
+ /// Why the test above cannot be satisfied by editing the result in
+ /// place: MSTest holds the failure text somewhere this assembly cannot
+ /// name, so there is more to clear than the four public ones. If this
+ /// ever fails, MSTest has stopped keeping that copy and the copying in
+ /// can be reconsidered.
+ ///
+ [TestMethod]
+ public void MSTestKeepsTextBesideTheMembersThisSuiteCanSet()
+ {
+ var named = new[]
+ {
+ nameof(TestResult.DisplayName),
+ nameof(TestResult.LogOutput),
+ nameof(TestResult.LogError),
+ nameof(TestResult.DebugTrace),
+ nameof(TestResult.TestContextMessages),
+ };
+ var unnamed = 0;
+ foreach (var field in TextFields())
+ {
+ if (Array.Exists(
+ named,
+ name => field.Name.Contains(name, StringComparison.Ordinal))
+ == false)
+ {
+ unnamed++;
+ }
+ }
+ Assert.IsTrue(
+ unnamed > 0,
+ "TestResult no longer holds text outside the members this "
+ + "suite sets by name, so the reason for copying the result "
+ + "rather than editing it has gone.");
+ }
+
+ ///
+ /// The redaction has to leave the failure readable, or a build log says
+ /// only that something went wrong.
+ ///
+ [TestMethod]
+ public void TheRedactedFailureStillSaysWhatWentWrong()
+ {
+ var reported = Browser51DidTestAttribute.Redacted(
+ ResultMentioningTheKeyThroughout(), Key);
+ var message = reported.TestFailureException!.ToString();
+ StringAssert.Contains(
+ message,
+ "the script at https://cloud.example/.js",
+ "the address that failed must still be readable with the key "
+ + "taken out of it.");
+ }
+
+ ///
+ /// A skip is a result too, and its reason reaches the same log.
+ ///
+ [TestMethod]
+ public void AnInconclusiveResultStaysInconclusiveAndIsRedacted()
+ {
+ var skipped = new TestResult
+ {
+ Outcome = UnitTestOutcome.Inconclusive,
+ TestFailureException = new AssertInconclusiveException(Mentions),
+ };
+ var reported = Browser51DidTestAttribute.Redacted(skipped, Key);
+ Assert.AreEqual(
+ UnitTestOutcome.Inconclusive,
+ reported.Outcome,
+ "a skip must still report as a skip.");
+ Assert.IsInstanceOfType(
+ reported.TestFailureException,
+ "a skip reported through a failure exception is counted as a "
+ + "failure.");
+ Assert.IsFalse(
+ reported.TestFailureException!.ToString()
+ .Contains(Key, StringComparison.Ordinal),
+ "the skip reason still carries the resource key.");
+ }
+
+ ///
+ /// The copy must not quietly drop what the runner uses to place a
+ /// result, which is what makes a data row's result its own.
+ ///
+ [TestMethod]
+ public void TheCopyKeepsWhatIdentifiesTheResult()
+ {
+ var original = new TestResult
+ {
+ DisplayName = "a name with no key in it",
+ Outcome = UnitTestOutcome.Passed,
+ Duration = TimeSpan.FromSeconds(3),
+ ExecutionId = Guid.NewGuid(),
+ ParentExecId = Guid.NewGuid(),
+ ResultFiles = new List { "screenshot.png" },
+ };
+ var reported = Browser51DidTestAttribute.Redacted(original, Key);
+ Assert.AreEqual(original.DisplayName, reported.DisplayName);
+ Assert.AreEqual(original.Outcome, reported.Outcome);
+ Assert.AreEqual(original.Duration, reported.Duration);
+ Assert.AreEqual(original.ExecutionId, reported.ExecutionId);
+ Assert.AreEqual(original.ParentExecId, reported.ParentExecId);
+ CollectionAssert.AreEqual(
+ (List)original.ResultFiles!,
+ (List?)reported.ResultFiles,
+ "a result file the test left behind must still be reported.");
+ }
+
+ ///
+ /// The guard prints a slice of the cloud-rendered client script to show
+ /// which template it was built from. That script calls back on
+ /// addresses naming the resource key, so a slice of it can contain the
+ /// key wherever the marker happens to fall.
+ ///
+ [TestMethod]
+ public void TheGuardsEvidenceSliceIsRedacted()
+ {
+ var marker = "fod.complete";
+ var body =
+ "var url = 'https://cloud.example/" + Key + ".json';\n"
+ + "// " + marker + " is what says the script finished\n"
+ + "function done() { " + marker + "(); }";
+ var at = body.IndexOf(marker, StringComparison.Ordinal);
+ var evidence = Harness.Around(body, at, Key);
+ StringAssert.Contains(
+ evidence,
+ marker,
+ "the slice must still show the marker it was taken around.");
+ Assert.IsFalse(
+ evidence.Contains(Key, StringComparison.Ordinal),
+ "the guard's evidence still carries the resource key. It is "
+ + "written to the console from ClassInitialize, outside "
+ + "Browser51DidTestAttribute, so nothing else takes it out.");
+ }
+}
diff --git a/Browser51Did/TcString.cs b/Browser51Did/TcString.cs
new file mode 100644
index 0000000..7353c4f
--- /dev/null
+++ b/Browser51Did/TcString.cs
@@ -0,0 +1,80 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+///
+/// The smallest framework string that says which purposes a visitor
+/// granted. Only the purpose consent bits and the legitimate interest bits
+/// are set, because those are the only ones the server reads when it works
+/// out a usage from a string.
+///
+/// This is the same construction the cloud service's own unit tests use.
+/// It is repeated here rather than shared, because a browser test that
+/// quietly changed when a unit test helper changed would be worse than a
+/// small repetition.
+///
+///
+/// Every language's demo delivers from its stub
+/// consent platform, which is
+/// AAAAAAAAAAAAAAAAAAAAAAAAAP_wAAAA, and the consent test checks the
+/// string that reached the cloud is exactly this.
+///
+///
+public static class TcString
+{
+ private const int ConsentStart = 152;
+ private const int LegitimateInterestStart = 176;
+ private const int PurposeCount = 12;
+
+ ///
+ /// Enough bytes for both purpose runs, which is the whole of the core
+ /// segment the reader looks at.
+ ///
+ private const int Bytes = 24;
+
+ ///
+ /// The purposes the standard usage under the Model Terms for
+ /// Marketing needs.
+ ///
+ public static readonly int[] StandardPurposes = { 1, 2, 7, 8, 11 };
+
+ ///
+ /// Every purpose, which is what the personalized usage needs.
+ ///
+ public static readonly int[] AllPurposes =
+ { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 };
+
+ /// A string granting exactly the purposes given.
+ public static string ForPurposes(IEnumerable consented)
+ {
+ var bytes = new byte[Bytes];
+ foreach (var purpose in consented)
+ {
+ if (purpose < 1 || purpose > PurposeCount)
+ {
+ throw new ArgumentOutOfRangeException(nameof(consented));
+ }
+ var bit = ConsentStart + purpose - 1;
+ bytes[bit / 8] |= (byte)(1 << (7 - (bit % 8)));
+ }
+ // Nothing is granted on legitimate interest, so the run starting
+ // here stays clear. It is named so the layout is readable rather
+ // than being a number nobody can check.
+ _ = LegitimateInterestStart;
+ return Convert.ToBase64String(bytes)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+
+ /// A string granting everything, which decodes to
+ /// personalized.
+ public static string Personalized() => ForPurposes(AllPurposes);
+
+ /// A string granting the standard set, which decodes to
+ /// standard.
+ public static string Standard() => ForPurposes(StandardPurposes);
+}
diff --git a/Browser51Did/Visitor.cs b/Browser51Did/Visitor.cs
new file mode 100644
index 0000000..d21fd0d
--- /dev/null
+++ b/Browser51Did/Visitor.cs
@@ -0,0 +1,693 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using OpenQA.Selenium;
+
+namespace FiftyOne.Pipeline.Cloud.SeleniumTests.Browser51Did;
+
+/// One request a page made, as the page itself saw it.
+public sealed class RecordedRequest
+{
+ [JsonPropertyName("kind")] public string Kind { get; set; } = "";
+ [JsonPropertyName("method")] public string Method { get; set; } = "";
+ [JsonPropertyName("url")] public string Url { get; set; } = "";
+ [JsonPropertyName("body")] public string Body { get; set; } = "";
+ [JsonPropertyName("status")] public int Status { get; set; }
+ [JsonPropertyName("response")] public string Response { get; set; } = "";
+ [JsonPropertyName("done")] public bool Done { get; set; }
+ [JsonPropertyName("error")] public string? Error { get; set; }
+
+ ///
+ /// The value of a form key in the request body, or null where the key
+ /// is absent. The body is form encoded with spaces as plus signs,
+ /// which is what the client script sends.
+ ///
+ public string? Form(string key)
+ {
+ foreach (var pair in Body.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var split = pair.IndexOf('=');
+ var name = split < 0 ? pair : pair.Substring(0, split);
+ if (Uri.UnescapeDataString(name.Replace('+', ' ')) != key)
+ {
+ continue;
+ }
+ var value = split < 0 ? "" : pair.Substring(split + 1);
+ return Uri.UnescapeDataString(value.Replace('+', ' '));
+ }
+ return null;
+ }
+
+ /// How many times a form key appears in the body.
+ public int FormCount(string key)
+ => Body.Split('&', StringSplitOptions.RemoveEmptyEntries)
+ .Count(pair =>
+ {
+ var split = pair.IndexOf('=');
+ var name = split < 0 ? pair : pair.Substring(0, split);
+ return Uri.UnescapeDataString(name.Replace('+', ' ')) == key;
+ });
+
+ public override string ToString()
+ => $"{Kind} {Method} {Url} status={Status} done={Done} "
+ + $"body={Body}";
+}
+
+///
+/// One visitor, being a browser looking at the demo's pages as the
+/// publisher's sites. Everything a test asserts on is read back through
+/// here, so an assertion reads as a sentence about the page rather than as a
+/// lump of JavaScript. What it reads was put on the page by the demo, so a
+/// demo in another language that serves the same pages passes the same
+/// reads.
+///
+public sealed class Visitor : IDisposable
+{
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ ///
+ /// Finding PMP's dialog. It renders into a shadow
+ /// root attached to a plain div with no name of its own, so the root
+ /// is found by what is inside it.
+ ///
+ private const string Prelude = @"
+ function pmpRoot() {
+ var all = document.querySelectorAll('*');
+ for (var i = 0; i < all.length; i++) {
+ var r = all[i].shadowRoot;
+ if (r && r.querySelector('.pmp')) { return r; }
+ }
+ return null;
+ }
+ // Whether a visitor can actually see something. offsetParent alone
+ // is not enough, because a browser reports null for it on anything
+ // positioned fixed, which the dialog is, and the answer would then
+ // be no for a card that is plainly on the screen.
+ function visible(el) {
+ if (!el) { return false; }
+ var style = window.getComputedStyle(el);
+ if (style.display === 'none'
+ || style.visibility === 'hidden'
+ || Number(style.opacity) === 0) {
+ return false;
+ }
+ var box = el.getBoundingClientRect();
+ return box.width > 0 && box.height > 0;
+ }
+ ";
+
+ public IWebDriver Driver { get; }
+
+ /// The browser's name, for a failure message.
+ public string BrowserName { get; }
+
+ public Visitor(IWebDriver driver, string browserName)
+ {
+ Driver = driver;
+ BrowserName = browserName;
+ }
+
+ ///
+ /// Loads one of the demo's pages as one of the publisher sites, and
+ /// waits for the document to settle.
+ ///
+ public void Go(string site, string route)
+ {
+ Driver.Navigate().GoToUrl(Demo.Chosen.PageUrl(site, route));
+ Harness.Until(
+ () => Script("return document.readyState;") == "complete",
+ $"the {route} page at {site} finished loading");
+ }
+
+ public T? Script(string body, params object[] arguments)
+ {
+ var result = ((IJavaScriptExecutor)Driver)
+ .ExecuteScript(Prelude + body, arguments);
+ if (result is null)
+ {
+ return default;
+ }
+ if (result is T typed)
+ {
+ return typed;
+ }
+ return (T)Convert.ChangeType(
+ result, typeof(T), CultureInfo.InvariantCulture);
+ }
+
+ private string Read(string body)
+ => Script(body) ?? "[]";
+
+ #region What the page sent and was told
+
+ /// Every request the page made, oldest first.
+ public IReadOnlyList Requests()
+ => JsonSerializer.Deserialize>(
+ Read("return JSON.stringify("
+ + "(window.__51dTest && window.__51dTest.requests) || []);"),
+ Json) ?? new List();
+
+ ///
+ /// The client script's own requests, being the ones to the endpoint it
+ /// posts its evidence to, which the demo's mode names rather than this
+ /// assuming the cloud's.
+ ///
+ public IReadOnlyList ClientRequests()
+ => Requests()
+ .Where(r => r.Url.Contains(
+ Demo.Chosen.JsonEndpointPath,
+ StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ ///
+ /// PMP's calls to the shared store, being the read
+ /// on load and the write when a visitor agrees to share.
+ ///
+ public IReadOnlyList SharedStoreRequests()
+ => Requests()
+ .Where(r => r.Url.Contains(
+ "/api/v4/pmp/pref", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ /// Every console line, in the order they were written.
+ public IReadOnlyList Console()
+ => JsonSerializer.Deserialize>(
+ Read("return JSON.stringify("
+ + "(window.__51dTest && window.__51dTest.console) || []);"),
+ Json)
+ ?.Select(line => $"[{line.Level}] {line.Text}")
+ .ToList()
+ ?? new List();
+
+ private sealed class ConsoleLine
+ {
+ [JsonPropertyName("level")] public string Level { get; set; } = "";
+ [JsonPropertyName("text")] public string Text { get; set; } = "";
+ }
+
+ /// Console lines carrying the given text.
+ public IReadOnlyList ConsoleMatching(string text)
+ => Console()
+ .Where(line => line.Contains(text, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ /// The preferences PMP's action URL was fired with.
+ public IReadOnlyList Actions()
+ => JsonSerializer.Deserialize>(
+ Read("return JSON.stringify("
+ + "(window.__51dTest && window.__51dTest.actions) || []);"),
+ Json) ?? new List();
+
+ /// Whether the alternative button's own action fired.
+ public bool AlternativeFired()
+ => Script(
+ "return !!(window.__51dTest && window.__51dTest.altFired);");
+
+ ///
+ /// The identifiers handed to a change handler the page registered,
+ /// oldest first.
+ ///
+ public IReadOnlyList ChangeIdentifiers()
+ => JsonSerializer.Deserialize?>>(
+ Read("return JSON.stringify("
+ + "(window.__51dTest && window.__51dTest.changes) || []);"),
+ Json)
+ ?.Select(section =>
+ section != null
+ && section.TryGetValue("idprobglobal", out var value)
+ ? value ?? ""
+ : "")
+ .ToList()
+ ?? new List();
+
+ /// Every key the page has in its own local storage.
+ public IReadOnlyList LocalStorageKeys()
+ => JsonSerializer.Deserialize>(
+ Read(@"
+ var keys = [];
+ try {
+ for (var i = 0; i < localStorage.length; i++) {
+ keys.push(localStorage.key(i));
+ }
+ } catch (e) { }
+ return JSON.stringify(keys);"),
+ Json) ?? new List();
+
+ #endregion
+
+ #region The client script's object
+
+ /// Whether the client script's object exists on the page.
+ public bool HasClientObject(string objectName = Harness.DefaultObjectName)
+ => Script(
+ $"return typeof window['{objectName}'] === 'object'"
+ + $" && window['{objectName}'] !== null;");
+
+ ///
+ /// The global identifier the client script is holding, or an empty
+ /// string when it holds none. An identifier is created only once an
+ /// answer has reached the cloud, so an empty string is the normal
+ /// state of a first request that carried no answer.
+ ///
+ public string Identifier(string objectName = Harness.DefaultObjectName)
+ => Script(
+ $@"var o = window['{objectName}'];
+ return (o && o.fodid && o.fodid.idprobglobal) || '';") ?? "";
+
+ ///
+ /// Whether the object carries the user prompt block's own entry point,
+ /// which exists only in the new client script. This is the guard's
+ /// check made again on what the browser actually loaded, rather than
+ /// on what the endpoint served the test host.
+ ///
+ public bool HasTheNewClientScript(
+ string objectName = Harness.DefaultObjectName)
+ => Script(
+ $"var o = window['{objectName}'];"
+ + " return !!(o && typeof o.refresh === 'function');");
+
+ ///
+ /// The third party cookie result the client script resolved, as the
+ /// page sees it. Empty where the property has no value.
+ ///
+ public string ThirdPartyCookies(
+ string objectName = Harness.DefaultObjectName)
+ => Script(
+ $@"var o = window['{objectName}'];
+ var d = o && o.device;
+ var v = d && d.thirdpartycookiesenabled;
+ return v === undefined || v === null ? '' : String(v);") ?? "";
+
+ ///
+ /// The names of the snippet results the client script has stored for
+ /// this page view, which are what it must send with the request that
+ /// creates an identifier. Read from the page's own session storage,
+ /// where the script puts them, so the test does not have to know which
+ /// properties the resource key happens to carry.
+ ///
+ public IReadOnlyList SnippetValueNames(
+ string objectName = Harness.DefaultObjectName)
+ => JsonSerializer.Deserialize>(
+ Read($@"
+ var prefix = '{objectName}_data_';
+ var names = [];
+ try {{
+ for (var i = 0; i < sessionStorage.length; i++) {{
+ var key = sessionStorage.key(i);
+ if (key.indexOf(prefix) === 0) {{
+ names.push(key.substring(prefix.length));
+ }}
+ }}
+ }} catch (e) {{ }}
+ return JSON.stringify(names);"),
+ Json) ?? new List();
+
+ ///
+ /// The record of the last request's inputs the client script keeps, so
+ /// that a later page view with a different input asks again rather
+ /// than answering from the cache. It is stored as a plain string, and
+ /// null where nothing has been stored.
+ ///
+ /// The key is found by its ending rather than spelled out, so a
+ /// difference in naming between the template and this test shows up as
+ /// a failed assertion about the contents rather than as a test that
+ /// silently checks nothing.
+ ///
+ ///
+ public string? CacheRecord(string objectName = Harness.DefaultObjectName)
+ {
+ var value = Script($@"
+ try {{
+ for (var i = 0; i < sessionStorage.length; i++) {{
+ var key = sessionStorage.key(i);
+ if (key.indexOf('{objectName}') === 0
+ && key.indexOf('_inputs') === key.length - 7) {{
+ return sessionStorage.getItem(key);
+ }}
+ }}
+ }} catch (e) {{ }}
+ return null;");
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
+
+ ///
+ /// Whether the visitor is somewhere the regulation applies, as the
+ /// client script resolved it. Empty where the property has no value,
+ /// which is what a resource key that does not carry it produces and
+ /// what every key produces until the change adding it is released.
+ ///
+ public string IsGdpr(string objectName = Harness.DefaultObjectName)
+ => Script(
+ $@"var o = window['{objectName}'];
+ var groups = ['derived', 'device'];
+ for (var i = 0; i < groups.length; i++) {{
+ var g = o && o[groups[i]];
+ var v = g && g.isgdpr;
+ if (v !== undefined && v !== null) {{ return String(v); }}
+ }}
+ return '';") ?? "";
+
+ ///
+ /// Every script element on the page, by source. Used where a test has
+ /// to see that something added one.
+ ///
+ public IReadOnlyList ScriptSources()
+ => JsonSerializer.Deserialize>(
+ Read(@"
+ return JSON.stringify(
+ Array.prototype.slice
+ .call(document.querySelectorAll('script'))
+ .map(function (s) { return s.src || ''; })
+ .filter(function (s) { return s !== ''; }));"),
+ Json) ?? new List();
+
+ #endregion
+
+ #region PMP
+
+ /// Whether PMP has put anything on the page yet.
+ public bool PmpLoaded() => Script("return pmpRoot() !== null;");
+
+ /// Whether a named card is on the page and visible.
+ public bool CardVisible(string card)
+ => Script($@"
+ var root = pmpRoot();
+ if (!root) {{ return false; }}
+ return visible(root.querySelector('[data-card=""{card}""]'));");
+
+ ///
+ /// Whether the floating button is showing and no card is, which is
+ /// what a visitor who has already answered sees.
+ ///
+ /// Both templates are put on the page at once and the dialog is hidden
+ /// by a class on the container rather than being taken away, so this
+ /// asks what is visible rather than what exists.
+ ///
+ ///
+ /// The button is found by what it does rather than by its class,
+ /// because the PMP build renames every class in the stylesheet to one
+ /// or two letters, so a test written against the class names in the
+ /// source finds nothing in the bundle a publisher is actually served.
+ /// The data attributes are not renamed.
+ ///
+ ///
+ public bool BubbleOnly()
+ => Script(@"
+ var root = pmpRoot();
+ if (!root) { return false; }
+ if (!visible(root.querySelector('[data-action=""open""]'))) {
+ return false;
+ }
+ var cards = root.querySelectorAll('[data-card]');
+ for (var i = 0; i < cards.length; i++) {
+ if (visible(cards[i])) { return false; }
+ }
+ return true;");
+
+ ///
+ /// What the dialog looks like right now, in one line, for a failure
+ /// message. A test that says only that something was not visible
+ /// leaves the next person to open a browser by hand and find out why.
+ ///
+ public string PmpState()
+ => Script(@"
+ var root = pmpRoot();
+ if (!root) { return 'the PMP has rendered nothing'; }
+ function describe(el, name) {
+ if (!el) { return name + '=absent'; }
+ var box = el.getBoundingClientRect();
+ var style = window.getComputedStyle(el);
+ return name + '=' + (visible(el) ? 'visible' : 'hidden')
+ + '(' + Math.round(box.width) + 'x' + Math.round(box.height)
+ + ' display:' + style.display
+ + ' visibility:' + style.visibility
+ + ' opacity:' + style.opacity + ')';
+ }
+ var parts = [];
+ var container = root.querySelector('.pmp');
+ parts.push('container class=' + (container
+ ? container.className : 'absent'));
+ parts.push(describe(
+ root.querySelector('[data-action=""open""]'), 'bubble'));
+ var cards = root.querySelectorAll('[data-card]');
+ for (var i = 0; i < cards.length; i++) {
+ parts.push(describe(
+ cards[i], 'card:' + cards[i].getAttribute('data-card')));
+ }
+ return parts.join(', ');") ?? "unreadable";
+
+ /// Presses one of the dialog's buttons.
+ public void Press(string action)
+ {
+ var pressed = Script($@"
+ var root = pmpRoot();
+ if (!root) {{ return false; }}
+ var el = root.querySelector('[data-action=""{action}""]');
+ if (!el) {{ return false; }}
+ if (window.__51dTimeline) {{
+ window.__51dTimeline.atPress =
+ window.__51dTimeline.finished();
+ window.__51dTimeline.push(
+ performance.now(), 'pressed {action}');
+ }}
+ el.click();
+ return true;");
+ Assert.IsTrue(
+ pressed,
+ $"'{action}' was not on the page to press in {BrowserName}. "
+ + $"The console said: {string.Join(" | ", Console())}");
+ }
+
+ ///
+ /// The answer PMP is holding, through the getter it exposes
+ /// for a publisher. Empty where it holds none.
+ ///
+ public string PmpPreference()
+ => Script(@"
+ var api = window.__51d_pmp;
+ if (!api || typeof api.preference !== 'function') { return ''; }
+ var value = api.preference();
+ return value === undefined || value === null ? '' : String(value);")
+ ?? "";
+
+ /// Opens the dialog again, as a publisher's own link would.
+ public void OpenPmp()
+ => Script