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("window.__51d_pmp.open(); return null;"); + + /// + /// What the framework surface answers a ping with, being whether the + /// regulation is said to apply. + /// + public bool GdprApplies() + { + Script(@" + window.__51dPing = null; + window.__tcfapi('ping', 2, function (result) { + window.__51dPing = result; + }); + return null;"); + Harness.Until( + () => Script("return window.__51dPing !== null;"), + "the framework surface answered a ping"); + return Script("return !!window.__51dPing.gdprApplies;"); + } + + #endregion + + #region Waiting + + /// + /// Waits until the client script has finished the given number of + /// rounds. A round is one request to the cloud and the response to it, + /// so this is an ordering and not a clock. + /// + public void WaitForClientRounds(int rounds) + => Harness.Until( + () => ClientRequests().Count(r => r.Done) >= rounds, + $"the client script finished {rounds} round(s) in " + + $"{BrowserName}. It finished " + + $"{ClientRequests().Count(r => r.Done)}. {ServedSoFar()}. " + + $"The console said: {string.Join(" | ", Console())}"); + + /// + /// Which page the browser is on and how far it got, which is what + /// separates a page that never loaded from a page that loaded and then + /// did nothing. + /// + public string ServedSoFar() + { + try + { + return "the browser is on " + + Script( + "return location.href + ' titled \"' + document.title" + + " + '\" in state ' + document.readyState;"); + } + catch (WebDriverException error) + { + return "the browser could not say which page it is on " + + $"({error.Message})"; + } + } + + #region Timeline + + /// + /// Starts recording, frame by frame inside the page, when each of the + /// client script's requests is first seen and when it finishes, when + /// the share card is put into the dialog and when it first becomes + /// visible, with adding the moment of each press. + /// + /// The ordering assertion about the share card reads its two counts + /// from here, through . It used to + /// poll the page from the test, and failed 3 times in 7 runs before + /// these tests moved. On 15 September 2026 this timeline caught one of + /// those failures, with the card visible at 302 ms and the answered + /// round finishing at 500 ms, so the card had not waited. The poll had + /// first seen the card after 500 ms, and counted the round that had + /// finished in between. Counting inside the page, at the press and at + /// the first frame the card is visible, measures the same thing + /// without the delay of reaching into the page from outside. + /// + /// + public void StartTimeline() + => Script(@" + if (window.__51dTimeline) { return null; } + var t0 = performance.now(); + var events = []; + var path = arguments[0].toLowerCase(); + function finished() { + var n = 0; + var all = (window.__51dTest && window.__51dTest.requests) || []; + for (var i = 0; i < all.length; i++) { + if (all[i].done + && all[i].url.toLowerCase().indexOf(path) !== -1) { + n++; + } + } + return n; + } + var tl = window.__51dTimeline = { + push: function (at, what) { + events.push(Math.round(at - t0) + 'ms ' + what); + }, + read: function () { return events.join(' | '); }, + finished: finished, + atPress: -1, + atShareVisible: -1 + }; + var seen = {}; + var cardIn = false; + var cardVisible = false; + function tick() { + var now = performance.now(); + var all = (window.__51dTest && window.__51dTest.requests) || []; + for (var i = 0; i < all.length; i++) { + var r = all[i]; + if (r.url.toLowerCase().indexOf(path) === -1) { continue; } + if (!seen['s' + i]) { + seen['s' + i] = true; + tl.push(now, 'request ' + i + ' seen' + + (r.done ? ' already finished' : '') + + (r.body.indexOf('id.usage=') !== -1 + ? ' carrying an answer' : '')); + } + if (r.done && !seen['d' + i]) { + seen['d' + i] = true; + tl.push(now, 'request ' + i + ' finished'); + } + } + var root = pmpRoot(); + var card = root + ? root.querySelector('[data-card=""share""]') : null; + if (card && !cardIn) { + cardIn = true; + tl.push(now, 'share card in the dialog'); + } + if (card && !cardVisible && visible(card)) { + cardVisible = true; + tl.atShareVisible = finished(); + tl.push(now, 'share card visible'); + } + if (events.length < 200) { requestAnimationFrame(tick); } + } + tl.push(t0, 'recording started'); + requestAnimationFrame(tick); + return null;", + Demo.Chosen.JsonEndpointPath); + + /// + /// How many of the client script's rounds had finished at the moment of + /// the last press, and at the first frame in which the share card was + /// visible, both counted inside the page, or -1 for a moment that has + /// not happened yet. + /// + public (int AtPress, int AtShareVisible) RoundsFinishedAround() + { + var answer = Script( + "var t = window.__51dTimeline;" + + " return t ? t.atPress + '|' + t.atShareVisible : '-1|-1';") + ?? "-1|-1"; + var parts = answer.Split('|'); + return ( + int.Parse(parts[0], CultureInfo.InvariantCulture), + int.Parse(parts[1], CultureInfo.InvariantCulture)); + } + + /// What the timeline recorded, oldest first. + public string Timeline() + => Script( + "return window.__51dTimeline ? window.__51dTimeline.read() : " + + "'no timeline was recorded';") ?? "unreadable"; + + #endregion + + /// Waits until PMP has put its dialog on the page. + public void WaitForPmp() + => Harness.Until( + PmpLoaded, + $"PMP loaded in {BrowserName}. " + + $"{ServedSoFar()}. The console said: " + + $"{string.Join(" | ", Console())}"); + + /// Waits until a named card is showing. + public void WaitForCard(string card) + => Harness.Until( + () => CardVisible(card), + $"the {card} card appeared in {BrowserName}. " + + $"{ServedSoFar()}. The console said: " + + $"{string.Join(" | ", Console())}"); + + /// Waits until the client script's object exists. + public void WaitForClientObject( + string objectName = Harness.DefaultObjectName) + => Harness.Until( + () => HasClientObject(objectName), + $"an object called '{objectName}' appeared in {BrowserName}. " + + $"{ServedSoFar()}. The console said: " + + $"{string.Join(" | ", Console())}"); + + #endregion + + public void Dispose() + { + try + { + Driver.Quit(); + } + catch (Exception) + { + // A browser that has already gone is not a test failure. + } + Driver.Dispose(); + } +} diff --git a/Examples/ExampleApps.cs b/Examples/ExampleApps.cs index e15091c..fe7b8df 100644 --- a/Examples/ExampleApps.cs +++ b/Examples/ExampleApps.cs @@ -81,6 +81,86 @@ public static bool TryCreate( return true; } + private const string DemoLangVar = "DEMO_LANG"; + private const string DemoUrlVar = "DEMO_URL"; + + /// + /// The selected demo's language (DEMO_LANG, default "dotnet"). + /// + /// + /// A demo is chosen separately from the example the Contract tests + /// use, because the two are different apps. The GettingStarted-Web + /// examples prove a web integration works, and a demo carries every + /// page the Browser51Did tests drive, which each language mirrors so + /// that the same tests hold for all of them. DEMO_URL and DEMO_LANG + /// work the way EXAMPLE_URL and EXAMPLE_LANG do. + /// + public static string SelectedDemoLang => + Environment.GetEnvironmentVariable(DemoLangVar) ?? "dotnet"; + + /// + /// Attempts to create the demo for the current environment, being the + /// one already running at DEMO_URL, or else the DEMO_LANG demo + /// launched from its sibling checkout. Returns false when no demo is + /// registered for that language. + /// + public static bool TryCreateDemo( + out IExampleApp app, + out ExampleDescriptor descriptor, + out string skipReason) + { + Demos.TryGetValue(SelectedDemoLang, out descriptor); + var external = Environment.GetEnvironmentVariable(DemoUrlVar); + if (!string.IsNullOrEmpty(external)) + { + app = new ExternalExampleApp(new Uri(external)); + skipReason = null; + return true; + } + if (descriptor == null) + { + app = null; + skipReason = + $"No demo registered for DEMO_LANG='{SelectedDemoLang}'. " + + $"Known: {string.Join(", ", Demos.Keys)}."; + return false; + } + app = new SubprocessExampleApp(descriptor); + skipReason = null; + return true; + } + + /// + /// Per-language demos. Each serves the same pages under the same + /// routes, and reads its input data from the same two variables, + /// 51DEGREES_RESOURCE_KEY and 51DEGREES_CLOUD_ENDPOINT, so a demo in + /// another language is added here and nothing else in the suite + /// changes. The suite always hands the key over under the runtime + /// name, whichever of its two names the suite read it from. + /// + public static readonly IReadOnlyDictionary Demos = + new Dictionary + { + ["dotnet"] = new ExampleDescriptor( + Lang: "dotnet", + WorkingDir: Path.Combine( + RepoPaths.SiblingsRoot, + "device-detection-dotnet-examples", + "Examples", "Cloud", "pmp-web"), + Command: "dotnet", + Args: new[] { "run", "-c", "Release", "--no-launch-profile" }, + ReadinessPath: "/cloud/common", + StartupTimeoutSeconds: 180, + BuildEnv: o => new Dictionary + { + ["51DEGREES_RESOURCE_KEY"] = o.ResourceKey, + // the endpoint includes the api/v4 path, as every + // other 51DEGREES_CLOUD_ENDPOINT reader expects + ["51DEGREES_CLOUD_ENDPOINT"] = new Uri(o.CloudEndpoint, "api/v4/").ToString(), + ["ASPNETCORE_URLS"] = $"http://localhost:{o.Port}", + }), + }; + /// /// Options for starting , read from the /// environment. diff --git a/README.md b/README.md index 671b930..5bdbf4a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ sibling directory and run it at their integration-test step. |---|---|---| | `Contract` | An example app serves `51Degrees.core.js`, client-side evidence flows back, and the server-rendered page shows a real detection result. | Cloud CI (per example, vs `:8080`) **and** every API CI (vs the public cloud). | | `CloudInternal` | Cloud response behaviour through a browser: cache reuse, COEP/CORP headers, third-party cookies, client-side overrides, and the per-browser JS endpoints. | Cloud CI only (vs `:8080`). | +| `Browser51Did` | The 51Did user prompt work in Chrome and Firefox, driving a demo's pages. See [Browser51Did/README.md](Browser51Did/README.md). | Cloud CI when something they prove changed (vs `:8080`, dotnet demo). | | `Browser` | That a browser starts at all and runs the script on a page served from the test process. No cloud, no key, no example. | This repository's own CI, on every runner image it uses. | Select a subset with `--filter TestCategory=Contract` or @@ -50,6 +51,9 @@ and no keys are committed. | `CHROMEWEBDRIVER` / `GECKOWEBDRIVER` / `EDGEWEBDRIVER` | optional | A driver, or the directory holding one. GitHub's Linux runner images set these. | | `CHROME_BIN` / `FIREFOX_BIN` / `EDGE_BIN` | optional | The browser binary to drive, when it is not on the path. | | `EXAMPLE_URL` / `EXAMPLE_LANG` | `Contract` | The example app to test (CI / local). | +| `51DEGREES_CLOUD_ENDPOINT` / `51DEGREES_RESOURCE_KEY` | `Browser51Did` | Handed to the demo under these names, the ones every language's demo reads first. | +| `_51DEGREES_RESOURCE_KEY_51DID` | `Browser51Did` | Read where `51DEGREES_RESOURCE_KEY` is unset. The name CI sets, for a resource key carrying the 51Did product. | +| `DEMO_URL` / `DEMO_LANG` / `DEMO_MODE` | `Browser51Did` | The demo to test, and `cloud` or `pipeline` pages. | A missing variable only fails the tests that read it, and the failure names the variable. Nothing is read for the run as a whole, so a `Contract` run against a diff --git a/SeleniumApiTests.csproj b/SeleniumApiTests.csproj index 68d3447..b6ea1d4 100644 --- a/SeleniumApiTests.csproj +++ b/SeleniumApiTests.csproj @@ -32,6 +32,15 @@ + + diff --git a/TestsCommon/TestConfig.cs b/TestsCommon/TestConfig.cs index 15f5763..e262b83 100644 --- a/TestsCommon/TestConfig.cs +++ b/TestsCommon/TestConfig.cs @@ -30,6 +30,26 @@ public class TestConfig // Paid resource key, or null when it is not set. public string OptionalPaidResourceKey => Optional(PaidResourceKeyVariable); + // Resource key a demo is started with, read from the runtime name + // first and from the CI name where that is unset. The demo is always + // given it under the runtime name, the one every language's demo + // reads first. + public string DemoResourceKey => + RequireFirst(DemoResourceKeyVariable, DemoResourceKeyCiVariable); + // Cloud endpoint a demo is started with, including the api/v4 path. + // Read by every language's demo under the same name. + public string DemoCloudEndpoint => Require(DemoCloudEndpointVariable); + + // The names follow the 51Degrees naming scheme for keys. The runtime + // name is what a developer sets and is read first. The CI name starts + // with an underscore, because a shell cannot export a name that + // starts with a digit, and ends with the product the key must carry, + // which for these tests is 51Did, because they create identifiers + // for standard and personalized answers. + public const string DemoResourceKeyVariable = "51DEGREES_RESOURCE_KEY"; + public const string DemoResourceKeyCiVariable = "_51DEGREES_RESOURCE_KEY_51DID"; + public const string DemoCloudEndpointVariable = "51DEGREES_CLOUD_ENDPOINT"; + private static readonly object _syncLock = new object(); private static TestConfig _instance; @@ -71,5 +91,24 @@ public string Require(string name) return Optional(name) ?? throw new InvalidOperationException( $"Required environment variable '{name}' is not set."); } + + // Reads the first of several names that is set, and fails naming all + // of them, in the order they are read, when none is. Read through + // Optional, so a TestConfig given its own lookup uses it here too. + public string RequireFirst(params string[] names) + { + foreach (var name in names) + { + var value = Optional(name); + if (value != null) + { + return value; + } + } + throw new InvalidOperationException( + "Required environment variable '" + + string.Join("', or where that is unset '", names) + + "' is not set."); + } } }