diff --git a/db.ssmssln b/db.ssmssln index b05ef5e9..52d89ac2 100644 --- a/db.ssmssln +++ b/db.ssmssln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # SQL Server Management Studio Solution File, Format Version 14.00 VisualStudioVersion = 14.0.23107.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{4F2E2C19-372F-40D8-9FA7-9D2138C6997A}") = "db", "db\db.ssmssqlproj", "{3107E538-B771-4F20-A68F-E7B9827FCBE6}" +Project("{4F2E2C19-372F-40D8-9FA7-9D2138C6997A}") = "db", "src\db\db.ssmssqlproj", "{3107E538-B771-4F20-A68F-E7B9827FCBE6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/apicaller.core/apicaller.core.csproj b/src/apicaller.core/apicaller.core.csproj index 481e3593..c26d2934 100644 --- a/src/apicaller.core/apicaller.core.csproj +++ b/src/apicaller.core/apicaller.core.csproj @@ -6,12 +6,12 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/apiservice.core/Controllers/Makefile b/src/apiservice.core/Controllers/Makefile index 33bbc379..f91e3546 100644 --- a/src/apiservice.core/Controllers/Makefile +++ b/src/apiservice.core/Controllers/Makefile @@ -10,7 +10,7 @@ Accesscode_sm.dot: Accesscode.sm Accesscode_sm.png: Accesscode_sm.dot dot -T png -o Accesscode_sm.png Accesscode_sm.dot - xcopy /D /F /Y Accesscode_sm.png ..\..\doc\img + xcopy /D /F /Y Accesscode_sm.png ..\..\..\doc\img clean: -del Accesscode_sm.cs diff --git a/src/apiservice.core/apiservice.core.csproj b/src/apiservice.core/apiservice.core.csproj index 96552692..5c8b3642 100644 --- a/src/apiservice.core/apiservice.core.csproj +++ b/src/apiservice.core/apiservice.core.csproj @@ -15,17 +15,17 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/apitest.core/apitest.core.csproj b/src/apitest.core/apitest.core.csproj index 258c47ca..688c8d46 100644 --- a/src/apitest.core/apitest.core.csproj +++ b/src/apitest.core/apitest.core.csproj @@ -9,7 +9,7 @@ - + all diff --git a/src/asp.core/Startup.cs b/src/asp.core/Startup.cs index 32ee1579..f07d605a 100644 --- a/src/asp.core/Startup.cs +++ b/src/asp.core/Startup.cs @@ -54,6 +54,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseDeveloperExceptionPage(); // always for this demo app.UseExceptionHandler("/Error/Error"); app.UseMiddleware(); // Global.asax + app.UseMiddleware(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseSession(); diff --git a/src/asp.webforms/Web.config b/src/asp.webforms/Web.config index 1600f455..454dbc61 100644 --- a/src/asp.webforms/Web.config +++ b/src/asp.webforms/Web.config @@ -6,20 +6,17 @@ -
+
- + - - + @@ -47,18 +44,12 @@ - + - - + + \ No newline at end of file diff --git a/src/asp.webforms/asp.webforms.csproj b/src/asp.webforms/asp.webforms.csproj index c04338ad..c569518b 100644 --- a/src/asp.webforms/asp.webforms.csproj +++ b/src/asp.webforms/asp.webforms.csproj @@ -46,8 +46,8 @@ 4 - - ..\..\packages\AjaxControlToolkit.19.1.0\lib\net40\AjaxControlToolkit.dll + + ..\..\packages\AjaxControlToolkit.20.1.0\lib\net40\AjaxControlToolkit.dll ..\..\packages\EntityFramework.6.4.4\lib\net45\EntityFramework.dll diff --git a/src/asp.webforms/packages.config b/src/asp.webforms/packages.config index c2e710be..a906121f 100644 --- a/src/asp.webforms/packages.config +++ b/src/asp.webforms/packages.config @@ -1,6 +1,6 @@  - + diff --git a/src/asp.websharper.spa.fs/Startup.fs b/src/asp.websharper.spa.fs/Startup.fs index 6244ec4d..4e2ee08d 100644 --- a/src/asp.websharper.spa.fs/Startup.fs +++ b/src/asp.websharper.spa.fs/Startup.fs @@ -33,7 +33,8 @@ type Startup private () = member this.Configure(app: IApplicationBuilder, env: IWebHostEnvironment) = if (env.IsDevelopment()) then WebSharper.Web.Remoting.DisableCsrfProtection() |> ignore // Prevent HTTP 403 errors in GUI tests - app.UseDeveloperExceptionPage() |> ignore + app.UseDeveloperExceptionPage() + .UseMiddleware() |> ignore else app.UseHttpsRedirection() .UseHsts() |> ignore diff --git a/src/asp.websharper.spa.fs/wwwroot/triptych.html b/src/asp.websharper.spa.fs/wwwroot/triptych.html index adb88062..1b819763 100644 --- a/src/asp.websharper.spa.fs/wwwroot/triptych.html +++ b/src/asp.websharper.spa.fs/wwwroot/triptych.html @@ -13,6 +13,8 @@ } .calc-box { + position: relative; + z-index: 1; border: 1px solid black; border-radius: 10px; padding: 30px; diff --git a/src/asp.websharper.spa/Startup.cs b/src/asp.websharper.spa/Startup.cs index ef2802a6..33719b21 100644 --- a/src/asp.websharper.spa/Startup.cs +++ b/src/asp.websharper.spa/Startup.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Net.Http.Headers; using System; using WebSharper.AspNetCore; @@ -51,7 +50,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) if (env.IsDevelopment()) { WebSharper.Web.Remoting.DisableCsrfProtection(); // Prevent HTTP 403 errors in GUI tests - app.UseDeveloperExceptionPage(); + app.UseDeveloperExceptionPage() + .UseMiddleware(); } else { diff --git a/src/asp.websharper.spa/wwwroot/triptych.html b/src/asp.websharper.spa/wwwroot/triptych.html index adb88062..1b819763 100644 --- a/src/asp.websharper.spa/wwwroot/triptych.html +++ b/src/asp.websharper.spa/wwwroot/triptych.html @@ -13,6 +13,8 @@ } .calc-box { + position: relative; + z-index: 1; border: 1px solid black; border-radius: 10px; padding: 30px; diff --git a/src/asplib.core/asplib.core.csproj b/src/asplib.core/asplib.core.csproj index 0aacfbd5..f2ffe5b8 100644 --- a/src/asplib.core/asplib.core.csproj +++ b/src/asplib.core/asplib.core.csproj @@ -19,11 +19,11 @@ - + - - - + + + diff --git a/src/asplib.websharper/Remoting/RequestQuerySessionMiddleware.cs b/src/asplib.websharper/Remoting/RequestQuerySessionMiddleware.cs index 07038877..946394ef 100644 --- a/src/asplib.websharper/Remoting/RequestQuerySessionMiddleware.cs +++ b/src/asplib.websharper/Remoting/RequestQuerySessionMiddleware.cs @@ -1,9 +1,9 @@ using asplib.Model; using Microsoft.AspNetCore.Http; +using System; using System.Collections.Generic; -using System.Threading.Tasks; using System.Net; -using System; +using System.Threading.Tasks; namespace asplib.Remoting { diff --git a/src/asptest.websharper.spa.fs/Calculator/CalculateTest.cs b/src/asptest.websharper.spa.fs/Calculator/CalculateTest.cs index 879eb2c9..0a662731 100644 --- a/src/asptest.websharper.spa.fs/Calculator/CalculateTest.cs +++ b/src/asptest.websharper.spa.fs/Calculator/CalculateTest.cs @@ -2,10 +2,12 @@ using iselenium; using NUnit.Framework; using OpenQA.Selenium; +using OpenQA.Selenium.IE; using static asplib.View.TagHelper; namespace asptest.Calculator { + [TestFixture(typeof(InternetExplorerDriver))] public class CalculateTest : CalculatorTestBase where TWebDriver : IWebDriver, new() { diff --git a/src/asptest.websharper.spa.fs/Calculator/FibonacciTest.cs b/src/asptest.websharper.spa.fs/Calculator/FibonacciTest.cs index 461d2f9c..59779673 100644 --- a/src/asptest.websharper.spa.fs/Calculator/FibonacciTest.cs +++ b/src/asptest.websharper.spa.fs/Calculator/FibonacciTest.cs @@ -43,7 +43,7 @@ public void VerifyFibonacciSums() // Check the correctness of the Fibonacci sequence in the calculator GUI // Delete the current sum and recalculate it from the sequence - this.Click(Id(CalculatorDoc.ClrButton)); + this.Click(Id(CalculatorDoc.ClrButton), wait: 15); this.Click(Id(CalculatorDoc.AddButton)); this.AssertPoll(() => this.Stack.ElementAt(0), () => Is.EqualTo(sum)); diff --git a/src/asptest.websharper.spa.fs/Calculator/WithSessionTest.cs b/src/asptest.websharper.spa.fs/Calculator/WithSessionTest.cs index 2d13633a..ce875c49 100644 --- a/src/asptest.websharper.spa.fs/Calculator/WithSessionTest.cs +++ b/src/asptest.websharper.spa.fs/Calculator/WithSessionTest.cs @@ -48,23 +48,22 @@ private void Reload() [Test] public void AddWithPersistenceTest() { - this.Navigate("/"); - this.Click("EnterButton"); + this.Click(Id(CalculatorDoc.EnterButton)); this.AssertPoll(() => this.State, () => Is.EqualTo(CalculatorContext.Map1.Enter)); this.Reload(); this.Write(Id(CalculatorDoc.OperandTextbox), "2"); - this.Click("EnterButton"); + this.Click(Id(CalculatorDoc.EnterButton)); this.AssertPoll(() => this.State, () => Is.EqualTo(CalculatorContext.Map1.Calculate)); this.Reload(); - this.Click("EnterButton"); + this.Click(Id(CalculatorDoc.EnterButton)); this.AssertPoll(() => this.State, () => Is.EqualTo(CalculatorContext.Map1.Enter)); this.Reload(); this.Write(Id(CalculatorDoc.OperandTextbox), "3"); - this.Click("EnterButton"); + this.Click(Id(CalculatorDoc.EnterButton)); this.AssertPoll(() => this.State, () => Is.EqualTo(CalculatorContext.Map1.Calculate)); var before = this.Stack.Count; this.Reload(); - this.Click("AddButton"); + this.Click(Id(CalculatorDoc.AddButton)); this.AssertAddFinalState(before); this.Reload(); } diff --git a/src/asptest.websharper.spa.fs/TriptychTest.cs b/src/asptest.websharper.spa.fs/TriptychTest.cs index b87424fe..134a87b4 100644 --- a/src/asptest.websharper.spa.fs/TriptychTest.cs +++ b/src/asptest.websharper.spa.fs/TriptychTest.cs @@ -17,10 +17,23 @@ namespace asptest public class TriptychTest : CalculatorTestBase where TWebDriver : IWebDriver, new() { + public override void OneTimeSetUpBrowser() + { + base.OneTimeSetUpBrowser(); + driver.Manage().Window.Size = new System.Drawing.Size(1450, 1000); + } + + [OneTimeSetUp] + public void DistinctPages() + { + this.samePageDefault = false; + } + [SetUp] - public void UnsetStorage() + public void ReloadUnsetStorage() { - StorageImplementation.SessionStorage = null; + this.Navigate("/"); + StorageImplementation.SessionStorage = null; // defaults to ViewState (DOM) } [TearDown] @@ -36,47 +49,50 @@ public void ClearStorage() [Test] public void NavigateTriptychTest() { + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); // init view this.Click(Id(CalculatorDoc.StorageLink)); this.AssertTriptychHtml(); } - /// - /// Assert the presence of the three calculators superficially by text. - /// - private void AssertTriptychHtml() - { - Assert.Multiple(() => - { - // Assert from bottom to top to ensure the page has been fully rendered on the client - this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Database")); - this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Session")); - this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); - }); - } - [Test] public void CircumambulateStorageTypes() { + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); // init view this.Click(Id(CalculatorDoc.StorageLink)); this.AssertTriptychHtml(); this.Click(Id(TriptychDoc.DatabaseDoc, CalculatorDoc.StorageLink)); - this.Navigate("/"); // Reload, as static StorageImplementation.SessionStorage is set too late in F# + this.driver.Navigate().Refresh(); // only in the F# port + // Poll SessionStorage first, this.Html() is ambiguous and succeeds too early (still on the triptych) this.AssertPoll(() => this.ViewModel.SessionStorage, () => Is.EqualTo(Storage.Database)); this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Database")); this.Click(Id(CalculatorDoc.StorageLink)); this.AssertTriptychHtml(); this.Click(Id(TriptychDoc.SessionDoc, CalculatorDoc.StorageLink)); - this.Navigate("/"); + this.driver.Navigate().Refresh(); this.AssertPoll(() => this.ViewModel.SessionStorage, () => Is.EqualTo(Storage.Session)); this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Session")); this.Click(Id(CalculatorDoc.StorageLink)); this.AssertTriptychHtml(); this.Click(Id(TriptychDoc.ViewStateDoc, CalculatorDoc.StorageLink)); - this.Navigate("/"); + this.driver.Navigate().Refresh(); this.AssertPoll(() => this.ViewModel.SessionStorage, () => Is.EqualTo(Storage.ViewState)); this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); } + + /// + /// Assert the presence of the three calculators superficially by text. + /// + private void AssertTriptychHtml() + { + Assert.Multiple(() => + { + // Assert from bottom to top to ensure the page has been fully rendered on the client + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Database")); + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Session")); + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); + }); + } } } \ No newline at end of file diff --git a/src/asptest.websharper.spa/Calculator/FibonacciTest.cs b/src/asptest.websharper.spa/Calculator/FibonacciTest.cs index 32c212ee..fdf24de0 100644 --- a/src/asptest.websharper.spa/Calculator/FibonacciTest.cs +++ b/src/asptest.websharper.spa/Calculator/FibonacciTest.cs @@ -43,7 +43,7 @@ public void VerifyFibonacciSums() // Check the correctness of the Fibonacci sequence in the calculator GUI // Delete the current sum and recalculate it from the sequence - this.Click(Id(CalculatorDoc.ClrButton)); + this.Click(Id(CalculatorDoc.ClrButton), wait: 15); this.Click(Id(CalculatorDoc.AddButton)); this.AssertPoll(() => this.Stack.ElementAt(0), () => Is.EqualTo(sum)); diff --git a/src/asptest.websharper.spa/TriptychTest.cs b/src/asptest.websharper.spa/TriptychTest.cs index 5dc68f08..95d06713 100644 --- a/src/asptest.websharper.spa/TriptychTest.cs +++ b/src/asptest.websharper.spa/TriptychTest.cs @@ -17,10 +17,23 @@ namespace asptest public class TriptychTest : CalculatorTestBase where TWebDriver : IWebDriver, new() { + public override void OneTimeSetUpBrowser() + { + base.OneTimeSetUpBrowser(); + driver.Manage().Window.Size = new System.Drawing.Size(1450, 1000); + } + + [OneTimeSetUp] + public void DistinctPages() + { + this.samePageDefault = false; + } + [SetUp] - public void UnsetStorage() + public void ReloadUnsetStorage() { - StorageImplementation.SessionStorage = null; // defaults to ViewState + this.Navigate("/"); + StorageImplementation.SessionStorage = null; // defaults to ViewState (DOM) } [TearDown] @@ -36,27 +49,15 @@ public void ClearStorage() [Test] public void NavigateTriptychTest() { + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); // init view this.Click(Id(CalculatorDoc.StorageLink)); this.AssertTriptychHtml(); } - /// - /// Assert the presence of the three calculators superficially by text. - /// - private void AssertTriptychHtml() - { - Assert.Multiple(() => - { - // Assert from bottom to top to ensure the page has been fully rendered on the client - this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Database")); - this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Session")); - this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); - }); - } - [Test] public void CircumambulateStorageTypes() { + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); // init view this.Click(Id(CalculatorDoc.StorageLink)); this.AssertTriptychHtml(); this.Click(Id(TriptychDoc.DatabaseDoc, CalculatorDoc.StorageLink)); @@ -76,5 +77,19 @@ public void CircumambulateStorageTypes() this.AssertPoll(() => this.ViewModel.SessionStorage, () => Is.EqualTo(Storage.ViewState)); this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); } + + /// + /// Assert the presence of the three calculators superficially by text. + /// + private void AssertTriptychHtml() + { + Assert.Multiple(() => + { + // Assert from bottom to top to ensure the page has been fully rendered on the client + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Database")); + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: Session")); + this.AssertPoll(() => this.Html(), () => Does.Contain("Session Storage: ViewState")); + }); + } } } \ No newline at end of file diff --git a/src/asptestrunner.core/Runner.cs b/src/asptestrunner.core/Runner.cs index 2ba3458c..182bce35 100644 --- a/src/asptestrunner.core/Runner.cs +++ b/src/asptestrunner.core/Runner.cs @@ -27,6 +27,7 @@ public void TearDown() public void RunTests() { this.Navigate("/"); + this.driver.Navigate().Refresh(); this.ClickID("testButton"); this.AssertTestsOK(); } diff --git a/src/asptestrunner.core/asptestrunner.core.csproj b/src/asptestrunner.core/asptestrunner.core.csproj index 3b567989..fc240a02 100644 --- a/src/asptestrunner.core/asptestrunner.core.csproj +++ b/src/asptestrunner.core/asptestrunner.core.csproj @@ -20,6 +20,8 @@ + + diff --git a/src/asptestrunner.websharper.spa/CSharpRunner.cs b/src/asptestrunner.websharper.spa/CSharpRunner.cs index 9f2ec587..76a2ab62 100644 --- a/src/asptestrunner.websharper.spa/CSharpRunner.cs +++ b/src/asptestrunner.websharper.spa/CSharpRunner.cs @@ -27,9 +27,10 @@ public void TearDown() [Test] public void RunTests() { - this.Navigate("/", delay: 7000); // server not yet ready on 1st run + this.Navigate("/", delay: 7000); // server not yet ready on 1st run + this.driver.Navigate().Refresh(); // and the page sometimes doesn't load the 1st time this.AssertPoll(() => this.GetHTMLElementById("testButton").Displayed, () => Is.True); - this.Click("testButton"); + this.Click("testButton", awaitRemoved: false); this.AssertTestsOK(); } } diff --git a/src/asptestrunner.websharper.spa/FSharpRunner.cs b/src/asptestrunner.websharper.spa/FSharpRunner.cs index f680cb02..2cf17f39 100644 --- a/src/asptestrunner.websharper.spa/FSharpRunner.cs +++ b/src/asptestrunner.websharper.spa/FSharpRunner.cs @@ -27,9 +27,10 @@ public void TearDown() [Test] public void RunTests() { - this.Navigate("/", delay: 7000); // server not yet ready on 1st run + this.Navigate("/", delay: 7000); // server not yet ready on 1st run + this.driver.Navigate().Refresh(); // and the page sometimes doesn't load the 1st time this.AssertPoll(() => this.GetHTMLElementById("testButton").Displayed, () => Is.True); - this.Click("testButton"); + this.Click("testButton", awaitRemoved: false); this.AssertTestsOK(); } } diff --git a/src/asptestrunner.websharper.spa/appsettings.json b/src/asptestrunner.websharper.spa/appsettings.json index 6ed652d5..36b4b02f 100644 --- a/src/asptestrunner.websharper.spa/appsettings.json +++ b/src/asptestrunner.websharper.spa/appsettings.json @@ -4,6 +4,6 @@ "ServerFSharp": "..\\..\\..\\..\\asp.websharper.spa.fs\\bin\\Debug\\netcoreapp3.1\\asp.websharper.spa.fs.exe", "RootCFharp": "..\\..\\..\\..\\asp.websharper.spa.fs", "Port": "5000", - "RequestTimeout": "350", + "RequestTimeout": "1000", "ServerStartTimeout": "10" } \ No newline at end of file diff --git a/src/asptestrunner.websharper.spa/asptestrunner.websharper.spa.csproj b/src/asptestrunner.websharper.spa/asptestrunner.websharper.spa.csproj index 14d98b0f..c9cca7e7 100644 --- a/src/asptestrunner.websharper.spa/asptestrunner.websharper.spa.csproj +++ b/src/asptestrunner.websharper.spa/asptestrunner.websharper.spa.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/src/iselenium.core/ISeleniumExtension.cs b/src/iselenium.core/ISeleniumExtension.cs index e73ef937..5101a91c 100644 --- a/src/iselenium.core/ISeleniumExtension.cs +++ b/src/iselenium.core/ISeleniumExtension.cs @@ -10,18 +10,19 @@ public static class SeleniumExtension /// Click the HTML element (usually a Button) with the given id and /// index and wait for the response when expectPostBack is true (default). /// - /// HTML id attribute of the element to click on - /// Whether to expect a GET/POST request to the server from the click - /// Whether to expect a WebForms style PostBack to the same page with the same HTML element - /// Expected StatusCofe of the response - /// Optional delay time in milliseconds before clicking the element - /// Optional pause time in milliseconds after IE claims DocumentComplete + /// HTML id attribute of the element to click on + /// Whether to expect a GET/POST request to the server from the click + /// Whether to expect a WebForms style PostBack to the same page with the same HTML element + /// Whether to wait for the HTML element to disappear (in an SPA) + /// Expected StatusCofe of the response + /// Optional delay time in milliseconds before clicking the element + /// Optional pause time in milliseconds after IE claims DocumentComplete public static void Click(this ISelenium inst, string id, int index = 0, - bool expectRequest = true, bool samePage = false, + bool expectRequest = true, bool samePage = false, bool awaitRemoved = false, int expectedStatusCode = 200, int delay = 0, int pause = 0) { SeleniumExtensionBase.ClickID(inst, id, index, - expectRequest: expectRequest, samePage: samePage, + expectRequest: expectRequest, samePage: samePage, awaitRemoved: awaitRemoved, expectedStatusCode: expectedStatusCode, delay: delay, pause: pause); } diff --git a/src/iselenium.core/NoCacheMiddleware.cs b/src/iselenium.core/NoCacheMiddleware.cs new file mode 100644 index 00000000..421bdc0d --- /dev/null +++ b/src/iselenium.core/NoCacheMiddleware.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using System.Threading.Tasks; + +namespace iselenium +{ + // https://support.microsoft.com/en-us/help/234067/how-to-prevent-caching-in-internet-explorer + // But even this does not stop IE from getting 304 responses and the test runner still requires two loads. + + /// + /// Send no-cache and expires headers + /// + public class NoCacheMiddleware + { + protected readonly RequestDelegate _next; + + public NoCacheMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + context.Response.Headers[HeaderNames.CacheControl] = "no-cache"; + context.Response.Headers[HeaderNames.Pragma] = "no-cache"; + context.Response.Headers[HeaderNames.Expires] = "-1"; + await _next(context); + } + } +} \ No newline at end of file diff --git a/src/iselenium.core/SpaTest.cs b/src/iselenium.core/SpaTest.cs index 26ffc5c5..69bc9059 100644 --- a/src/iselenium.core/SpaTest.cs +++ b/src/iselenium.core/SpaTest.cs @@ -16,6 +16,21 @@ namespace iselenium public abstract class SpaTest : SeleniumTest where TWebDriver : IWebDriver, new() { + /// + /// Globally set the awaitRemove: default for Click(), as simple SPA + /// pages (as the minimal one) don't require awaitRemove: true, but + /// complex ones on almost every clickable element. + /// + protected bool awaitRemovedDefault = true; + + /// + /// Globally set the samePage: default for Click(). True if the + /// interaction with a single page with static elements is tested, false + /// if the test jumps around several pages. + /// Relevant if awaitRemovedDefault is true. + /// + protected bool samePageDefault = true; + /// /// Start the browser and poll the root directory to be available (SPAs /// are known to have a long startup time) @@ -58,6 +73,7 @@ public string Html(int wait = 0) return SeleniumExtensionBase.Html(this, wait: (wait == 0) ? SeleniumExtensionBase.RequestTimeout : wait); } + /// /// Click the HTML element (usually a Button) with the given id and /// index, don't wait for a response as expectRequest defaults to false for an SPA, /// but wait RequestTimeout seconds for the element to appear. @@ -66,17 +82,20 @@ public string Html(int wait = 0) /// Index of the element collection with that id, defaults to 0 /// Whether to expect a GET/POST request to the server from the click /// Whether to expect a WebForms style PostBack to the same page with the same HTML element + /// Whether to wait for the HTML element to disappear (in an SPA) /// Expected StatusCofe of the response /// Optional delay time in milliseconds before clicking the element /// Optional pause time in milliseconds after IE claims DocumentComplete /// Explicit WebDriverWait in seconds for the element to appear public void Click(string id, int index = 0, - bool expectRequest = false, bool samePage = false, + bool expectRequest = false, bool? samePage = null, bool? awaitRemoved = null, int expectedStatusCode = 200, int delay = 0, int pause = 0, int wait = 0) { + var doAwaitRemoved = awaitRemoved ?? this.awaitRemovedDefault; + var onSamePage = samePage ?? this.samePageDefault; SeleniumExtensionBase.ClickID(this, id, index, - expectRequest: expectRequest, samePage: samePage, - expectedStatusCode: expectedStatusCode, + expectRequest: expectRequest, samePage: onSamePage, + awaitRemoved: doAwaitRemoved, expectedStatusCode: expectedStatusCode, delay: delay, pause: pause, wait: (wait == 0) ? SeleniumExtensionBase.RequestTimeout : wait); } diff --git a/src/iselenium.webforms/ISeleniumExtension.cs b/src/iselenium.webforms/ISeleniumExtension.cs index 4db36ac7..4f09b925 100644 --- a/src/iselenium.webforms/ISeleniumExtension.cs +++ b/src/iselenium.webforms/ISeleniumExtension.cs @@ -30,13 +30,16 @@ public static class SeleniumExtension /// /// Member name path to the control starting at the main control /// Whether to expect a server request from the click + /// Whether the PostBack stays on the same page with the same HTML element /// Expected StatusCofe of the response /// Optional delay time in milliseconds before clicking the element /// Optional pause time in milliseconds after IE claims DocumentComplete - public static void Click(this ISelenium inst, string path, bool expectPostBack = true, int expectedStatusCode = 200, int delay = 0, int pause = 0) + public static void Click(this ISelenium inst, string path, bool expectPostBack = true, bool samePage = false, + int expectedStatusCode = 200, int delay = 0, int pause = 0) { var button = GetControl(inst, path); - SeleniumExtensionBase.ClickID(inst, button.ClientID, expectRequest: expectPostBack, samePage: false, + SeleniumExtensionBase.ClickID(inst, button.ClientID, + expectRequest: expectPostBack, samePage: samePage, expectedStatusCode: expectedStatusCode, delay: delay, pause: pause); } @@ -46,13 +49,16 @@ public static void Click(this ISelenium inst, string path, bool expectPostBack = /// /// The ASP.NET control to click on /// Whether to expect a server request from the click + /// Whether the PostBack stays on the same page with the same HTML element /// Expected StatusCode of the response /// Optional delay time in milliseconds before clicking the element /// Optional pause time in milliseconds after IE claims DocumentComplete - public static void Click(this ISelenium inst, Control control, bool expectPostBack = true, int expectedStatusCode = 200, int delay = 0, int pause = 0) + public static void Click(this ISelenium inst, Control control, bool expectPostBack = true, bool samePage = false, + int expectedStatusCode = 200, int delay = 0, int pause = 0) { - var button = GetHTMLElement(inst, control.ClientID); - SeleniumExtensionBase.Click(inst, button, expectPostBack, expectedStatusCode, delay, pause); + SeleniumExtensionBase.ClickID(inst, control.ClientID, + expectRequest: expectPostBack, samePage: samePage, + expectedStatusCode: expectedStatusCode, delay: delay, pause: pause); } /// @@ -81,7 +87,7 @@ public static void Select(this ISelenium inst, string path, string value, bool e else if (list.Items[idx].Value == value) { string itemID = String.Format("{0}_{1}", list.ClientID, idx); - SeleniumExtensionBase.ClickID(inst, itemID, expectRequest: expectPostBack, samePage: true, + SeleniumExtensionBase.ClickID(inst, itemID, expectRequest: expectPostBack, samePage: !expectPostBack, expectedStatusCode: expectedStatusCode, delay: delay, pause: pause); break; } diff --git a/src/iselenium/ISeleniumExtensionBase.cs b/src/iselenium/ISeleniumExtensionBase.cs index a3d64072..6f6234fc 100644 --- a/src/iselenium/ISeleniumExtensionBase.cs +++ b/src/iselenium/ISeleniumExtensionBase.cs @@ -74,7 +74,7 @@ public static void SetUpBrowser(this ISeleniumBase inst) /// /// [OneTimeTearDown] - /// Quit Internet Explorer + /// Quit the browser /// public static void TearDownBrowser(this ISeleniumBase inst) { @@ -167,18 +167,19 @@ public static void AssertTestsOK(this ISeleniumBase inst) /// /// HTML name attribute of the element to click on /// Whether to expect a GET/POST request to the server from the click - /// Whether to expect a WebForms style PostBack to the same page + /// Whether to expect a WebForms style PostBack to the same page with the same HTML element + /// Whether to wait for the HTML element to disappear (in an SPA) /// Expected StatusCofe of the response /// Optional delay time in milliseconds before clicking the element /// Optional pause time in milliseconds after IE claims DocumentComplete /// Explicit WebDriverWait in seconds for the element to appear public static void ClickName(this ISeleniumBase inst, string name, int index = 0, - bool expectRequest = true, bool samePage = false, + bool expectRequest = true, bool samePage = false, bool awaitRemoved = false, int expectedStatusCode = 200, int delay = 0, int pause = 0, int wait = 0) { var button = GetHTMLElementByName(inst, name, index, wait: wait); - Click(inst, button, expectRequest: expectRequest, delay: delay, pause: pause); - if (expectRequest && samePage) + Click(button, awaitRemoved, delay, pause); + if ((expectRequest || awaitRemoved) && samePage) { new WebDriverWait(inst.driver, TimeSpan.FromSeconds(RequestTimeout)) .Until(drv => drv.FindElement(By.Name(name)).Displayed); @@ -193,20 +194,20 @@ public static void AssertTestsOK(this ISeleniumBase inst) /// HTML id attribute of the element to click on /// Index of the element collection with that id, defaults to 0 /// Whether to expect a GET/POST request to the server from the click - /// Whether to expect a WebForms style PostBack to the same page + /// Whether to expect a WebForms style PostBack to the same page with the same HTML element + /// Whether to wait for the HTML element to disappear (in an SPA) /// Expected StatusCofe of the response/// /// Optional delay time in milliseconds before clicking the element /// Optional pause time in milliseconds after IE claims DocumentComplete /// Explicit WebDriverWait in seconds for the element to appear public static void ClickID(this ISeleniumBase inst, string id, int index = 0, - bool expectRequest = true, bool samePage = false, + bool expectRequest = true, bool samePage = false, bool awaitRemoved = false, int expectedStatusCode = 200, int delay = 0, int pause = 0, int wait = 0) { var button = GetHTMLElementById(inst, id, index, wait: wait); - Click(inst, button, expectRequest: expectRequest, expectedStatusCode: expectedStatusCode, - delay: delay, pause: pause); - if (expectRequest && samePage) + Click(button, awaitRemoved, delay, pause); + if ((expectRequest || awaitRemoved) && samePage) { new WebDriverWait(inst.driver, TimeSpan.FromSeconds(RequestTimeout)) .Until(drv => drv.FindElement(By.Id(id)).Displayed); @@ -215,24 +216,22 @@ public static void AssertTestsOK(this ISeleniumBase inst) } /// - /// Click on the HTML element and wait for the response when expectRequest is true. + /// Click on the HTML element /// /// The HTML element itself - /// Whether to expect a GET/POST request to the server from the click + /// Whether to wait for the HTML element to disappear (in an SPA) /// Expected StatusCofe of the response/// /// Optional delay time in milliseconds before clicking the element /// Optional pause time in milliseconds after IE claims DocumentComplete - public static void Click(this ISeleniumBase inst, IWebElement element, bool expectRequest = true, - int expectedStatusCode = 200, int delay = 0, int pause = 0) + private static void Click(IWebElement element, bool awaitRemoved, int delay, int pause) { Thread.Sleep(delay); element.Click(); - if (expectRequest) + if (awaitRemoved) { - AwaitBeginRequest(element); + AwaitElementRemoved(element); } Thread.Sleep(pause); - AssertStatusCode(inst, expectedStatusCode); } /// @@ -290,7 +289,7 @@ public static void AssertTestsOK(this ISeleniumBase inst) } else if (list[idx].GetAttribute("value") == value) { - ClickID(inst, id, idx, expectRequest: expectPostBack, samePage: true, expectedStatusCode: expectedStatusCode, + ClickID(inst, id, idx, expectRequest: expectPostBack, samePage: !expectPostBack, expectedStatusCode: expectedStatusCode, delay: delay, pause: pause); if (expectPostBack) { @@ -329,7 +328,7 @@ public static void AssertTestsOK(this ISeleniumBase inst) } else if (list[idx].GetAttribute("value") == value) { - ClickName(inst, name, idx, expectRequest: expectPostBack, samePage: true, expectedStatusCode: expectedStatusCode, + ClickName(inst, name, idx, expectRequest: expectPostBack, samePage: !expectPostBack, expectedStatusCode: expectedStatusCode, delay: delay, pause: pause); if (expectPostBack) { @@ -350,8 +349,7 @@ public static void AssertTestsOK(this ISeleniumBase inst) /// public static IWebElement GetHTMLElementById(this ISeleniumBase inst, string id, int index = 0, int wait = 0) { - var elements = new WebDriverWait(inst.driver, TimeSpan.FromSeconds(wait)) - .Until(drv => drv.FindElements(By.Id(id))); + var elements = AwaitHTMLElements(inst, By.Id, id, wait); if (elements.Count <= index) { throw new ArgumentException(String.Format( @@ -369,8 +367,7 @@ public static IWebElement GetHTMLElementById(this ISeleniumBase inst, string id, /// public static ReadOnlyCollection GetHTMLElementsByID(this ISeleniumBase inst, string id, int wait = 0) { - var elements = new WebDriverWait(inst.driver, TimeSpan.FromSeconds(wait)) - .Until(drv => drv.FindElements(By.Id(id))); + var elements = AwaitHTMLElements(inst, By.Id, id, wait); if (elements.Count == 0) { throw new ArgumentException(String.Format("HTML input element with id='{0}' not found", id)); @@ -392,14 +389,12 @@ public static ReadOnlyCollection GetHTMLElementsByID(this ISelenium /// public static IWebElement GetHTMLElementByName(this ISeleniumBase inst, string name, int index = 0, int wait = 0) { - var elements = new WebDriverWait(inst.driver, TimeSpan.FromSeconds(wait)) - .Until(drv => drv.FindElements(By.Name(name))); + var elements = AwaitHTMLElements(inst, By.Name, name, wait); if (elements.Count <= index) { if (IIECompatible(inst)) { - elements = new WebDriverWait(inst.driver, TimeSpan.FromSeconds(wait)) - .Until(drv => drv.FindElements(By.Id(name))); + elements = AwaitHTMLElements(inst, By.Id, name, wait); if (elements.Count <= index) { throw new ArgumentException(String.Format( @@ -423,8 +418,7 @@ public static IWebElement GetHTMLElementByName(this ISeleniumBase inst, string n /// public static ReadOnlyCollection GetHTMLElementsByName(this ISeleniumBase inst, string name, int wait = 0) { - var elements = new WebDriverWait(inst.driver, TimeSpan.FromSeconds(wait)) - .Until(drv => drv.FindElements(By.Name(name))); + var elements = AwaitHTMLElements(inst, By.Name, name, wait); if (elements.Count == 0) { throw new ArgumentException(String.Format("HTML input element with name='{0}' not found", name)); @@ -435,15 +429,46 @@ public static ReadOnlyCollection GetHTMLElementsByName(this ISeleni } } + /// + /// On Chrome, WebDriverWait might sometimes find an element during DOM + /// manipulation that has become stale. Ignore + /// StaleElementReferenceException and try again. + /// + /// delegate for By.Name(name) or By.Id(id) + /// argument for the selector + /// Explicit WebDriverWait for the elements + /// + public static ReadOnlyCollection AwaitHTMLElements(this ISeleniumBase inst, + Func selector, + string nameOrId, int wait) + { + var elements = new ReadOnlyCollection(Array.Empty()); + for (int i = 0; i < RequestTimeout * 1000 / FAST_POLL_MILLISECONDS; i++) + { + try + { + elements = new WebDriverWait(inst.driver, TimeSpan.FromSeconds(wait)) + .Until(drv => drv.FindElements(selector(nameOrId))); + if (elements.Count == 0) + return elements; // element not findable within WebDriverWait -> give up + var _ = elements[0].Displayed; // either the old one (throws) or the new one + return elements; // didn't throw -> return + } + catch (StaleElementReferenceException) { } // next try for the non-stale element + Thread.Sleep(FAST_POLL_MILLISECONDS); + } + return elements; + } + /// /// Explicitly wait for the element to disappear by rapidly polling /// its visibility until the reference has become stale or the /// RequestTimeout is exceeded. /// - /// - private static void AwaitBeginRequest(IWebElement element) + /// The HTML element that should disappear + private static void AwaitElementRemoved(IWebElement element) { - bool isPostBack = false; + bool isRemoved = false; for (int i = 0; i < RequestTimeout * 1000 / FAST_POLL_MILLISECONDS; i++) { try @@ -452,14 +477,16 @@ private static void AwaitBeginRequest(IWebElement element) } catch (StaleElementReferenceException) { - isPostBack = true; + isRemoved = true; break; } Thread.Sleep(FAST_POLL_MILLISECONDS); } - if (!isPostBack) + if (!isRemoved) { - throw new TimeoutException(String.Format("PostBack took longer than {0}s", RequestTimeout)); + throw new TimeoutException(String.Format( + "AwaitElementRemoved took longer than {0}s, if this is expected set awaitRemoved: false", + RequestTimeout)); } } diff --git a/src/iselenium/ISeleniumExtensionBaseAssertPoll.cs b/src/iselenium/ISeleniumExtensionBaseAssertPoll.cs index b2f6ad82..5ced01e3 100644 --- a/src/iselenium/ISeleniumExtensionBaseAssertPoll.cs +++ b/src/iselenium/ISeleniumExtensionBaseAssertPoll.cs @@ -47,9 +47,26 @@ public static partial class SeleniumExtensionBase /// The Type being compared. /// An ActualValueDelegate returning the value to be tested /// A Constraint expression to be applied - public static void AssertPoll(this ISeleniumBase _, ActualValueDelegate del, Func expr) + /// Time in seconds to poll for the assertion to pass, overrides RequestTimeout + public static void AssertPoll(this ISeleniumBase _, ActualValueDelegate del, + Func expr, int? timeout = null) { - AssertPoll(del, expr, null, null); + AssertPoll(del, expr, null, timeout: timeout, null); + } + + /// + /// Apply a constraint to a delegate. Returns without throwing an exception when inside a multiple assert block. + /// + /// The Type being compared. + /// An ActualValueDelegate returning the value to be tested + /// A Constraint expression to be applied + /// Exception message delegate + /// A Constraint expression to be applied + public static void AssertPoll(this ISeleniumBase _, ActualValueDelegate del, + Func expr, Func getExceptionMessage, + int? timeout = null) + { + AssertPoll(del, expr, getExceptionMessage, timeout: timeout); } /// @@ -60,9 +77,10 @@ public static void AssertPoll(this ISeleniumBase _, ActualValueDelegate /// A Constraint expression to be applied /// The message that will be displayed on failure /// Arguments to be used in formatting the message - public static void That(ActualValueDelegate del, Func expr, string message, params object[] args) + public static void That(ActualValueDelegate del, Func expr, + string message, params object[] args) { - AssertPoll(del, expr, message, args); + AssertPoll(del, expr, message, timeout: null, args); } /// @@ -86,15 +104,17 @@ public static void That(ActualValueDelegate del, Func(ActualValueDelegate del, Func expression, string message, params object[] args) + private static void AssertPoll(ActualValueDelegate del, Func expression, + string message, int? timeout = null, params object[] args) { - TryAssertPoll(del, expression); + TryAssertPoll(del, expression, timeout); Assert.That(del, expression(), message, args); } - private static void AssertPoll(ActualValueDelegate del, Func expression, Func getExceptionMessage) + private static void AssertPoll(ActualValueDelegate del, Func expression, + Func getExceptionMessage, int? timeout = null) { - TryAssertPoll(del, expression); + TryAssertPoll(del, expression, timeout); Assert.That(del, expression(), getExceptionMessage); } @@ -104,11 +124,15 @@ private static void AssertPoll(ActualValueDelegate del, Func /// /// - /// whether the tess passed - private static bool TryAssertPoll(ActualValueDelegate del, Func expression) + /// Overrides RequestTimeout + /// whether the test passed + private static bool TryAssertPoll(ActualValueDelegate del, + Func expression, + int? timeout) { + int pollFor = timeout ?? RequestTimeout; var constraint = expression().Resolve(); - for (int i = 0; i < RequestTimeout * 1000 / FAST_POLL_MILLISECONDS; i++) + for (int i = 0; i < pollFor * 1000 / FAST_POLL_MILLISECONDS; i++) { try { diff --git a/src/iselenium/ITestServerBase.cs b/src/iselenium/ITestServerBase.cs index 97f07e96..5f4feef5 100644 --- a/src/iselenium/ITestServerBase.cs +++ b/src/iselenium/ITestServerBase.cs @@ -52,12 +52,18 @@ public static void StopServer(this ITestServerBase inst) TestServerIPC.Dispose(); try { - if (inst != null) + if (inst.ServerProcess != null) { inst.ServerProcess.Kill(); + inst.ServerProcess.WaitForExit(); } } catch { } + finally + { + inst.ServerProcess.Dispose(); + inst.ServerProcess = null; + } } } } \ No newline at end of file diff --git a/src/minimal.core/Startup.cs b/src/minimal.core/Startup.cs index b5ec6182..3602889d 100644 --- a/src/minimal.core/Startup.cs +++ b/src/minimal.core/Startup.cs @@ -57,6 +57,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseDeveloperExceptionPage(); // always for this demo app.UseExceptionHandler("/Error/Error"); app.UseMiddleware(); // Global.asax + app.UseMiddleware(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseSession(); diff --git a/src/minimal.websharper.spa/Startup.cs b/src/minimal.websharper.spa/Startup.cs index 1812c0ed..051e4962 100644 --- a/src/minimal.websharper.spa/Startup.cs +++ b/src/minimal.websharper.spa/Startup.cs @@ -50,15 +50,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) //WebSharper.Web.Remoting.DisableCsrfProtection(); // HTTP 403 prevention not needed here app.UseDeveloperExceptionPage() .UseMiddleware() - // https://support.microsoft.com/en-us/help/234067/how-to-prevent-caching-in-internet-explorer - // But even this does not stop IE from sending 304 responses: - .Use(async (httpContext, next) => - { - httpContext.Response.Headers[HeaderNames.CacheControl] = "no-cache"; - httpContext.Response.Headers[HeaderNames.Pragma] = "no-cache"; - httpContext.Response.Headers[HeaderNames.Expires] = "-1"; - await next(); - }) + .UseMiddleware() .UseDefaultFiles() .UseSession() .UseMiddleware() diff --git a/src/minimaltest.websharper.spa/WithStaticRemoteTest.cs b/src/minimaltest.websharper.spa/WithStaticRemoteTest.cs index c18eb23a..f62b522d 100644 --- a/src/minimaltest.websharper.spa/WithStaticRemoteTest.cs +++ b/src/minimaltest.websharper.spa/WithStaticRemoteTest.cs @@ -9,13 +9,18 @@ namespace minimaltest { - //public class WithStaticRemoteTest : SpaTest // For TestFilter with 1 Browser [TestFixture(typeof(ChromeDriver))] [TestFixture(typeof(FirefoxDriver))] // at the speed of continental drift... [TestFixture(typeof(InternetExplorerDriver))] public class WithStaticRemoteTest : SpaTest where TWebDriver : IWebDriver, new() { + [OneTimeSetUp] + public void NoAwaitRemoved() + { + this.awaitRemovedDefault = false; + } + /// /// Typed accessor to the only model object in the app /// diff --git a/src/minimaltest.websharper.spa/WithStorageRemoteTest.cs b/src/minimaltest.websharper.spa/WithStorageRemoteTest.cs index 7bffdb52..82be7357 100644 --- a/src/minimaltest.websharper.spa/WithStorageRemoteTest.cs +++ b/src/minimaltest.websharper.spa/WithStorageRemoteTest.cs @@ -11,14 +11,17 @@ namespace minimaltest { - //public class WithStaticRemoteTest : SpaTest // For TestFilter with 1 Browser - //public class WithStorageRemoteTest : SpaTest // does not work with FireFox [TestFixture(typeof(ChromeDriver))] [TestFixture(typeof(InternetExplorerDriver))] public class WithStorageRemoteTest : SpaStorageTest where TWebDriver : IWebDriver, new() - //public class WithStorageRemoteTest : SpaTest { + [OneTimeSetUp] + public void NoAwaitRemoved() + { + this.awaitRemovedDefault = false; + } + /// /// Typed accessor to the only model object in the app /// diff --git a/src/minimaltestrunner.core/Runner.cs b/src/minimaltestrunner.core/Runner.cs index 9a7c8422..98b03384 100644 --- a/src/minimaltestrunner.core/Runner.cs +++ b/src/minimaltestrunner.core/Runner.cs @@ -27,6 +27,7 @@ public void TearDown() public void RunTests() { this.Navigate("/"); + this.driver.Navigate().Refresh(); this.ClickID("testButton"); this.AssertTestsOK(); } diff --git a/src/minimaltestrunner.websharper.spa/Runner.cs b/src/minimaltestrunner.websharper.spa/Runner.cs index 1c004d41..2cbd5bff 100644 --- a/src/minimaltestrunner.websharper.spa/Runner.cs +++ b/src/minimaltestrunner.websharper.spa/Runner.cs @@ -26,8 +26,10 @@ public void TearDown() [Test] public void RunTests() { - this.Navigate("/", delay: 7000); // server not yet ready on 1st run - this.Click("testButton"); + this.Navigate("/", delay: 7000); // server not yet ready on 1st run + this.driver.Navigate().Refresh(); // and the page sometimes doesn't load the 1st time + this.AssertPoll(() => this.GetHTMLElementById("testButton").Displayed, () => Is.True); + this.Click("testButton", awaitRemoved: false); this.AssertTestsOK(); } } diff --git a/src/minimaltestrunner.websharper.spa/minimaltestrunner.websharper.spa.csproj b/src/minimaltestrunner.websharper.spa/minimaltestrunner.websharper.spa.csproj index c3912352..5d563dfe 100644 --- a/src/minimaltestrunner.websharper.spa/minimaltestrunner.websharper.spa.csproj +++ b/src/minimaltestrunner.websharper.spa/minimaltestrunner.websharper.spa.csproj @@ -23,6 +23,7 @@ +