diff --git a/.azure/pipelines/blazor-daily-tests.yml b/.azure/pipelines/blazor-daily-tests.yml
new file mode 100644
index 0000000000..537751bfed
--- /dev/null
+++ b/.azure/pipelines/blazor-daily-tests.yml
@@ -0,0 +1,59 @@
+# Uses Scheduled Triggers, which aren't supported in YAML yet.
+# https://docs.microsoft.com/en-us/azure/devops/pipelines/build/triggers?view=vsts&tabs=yaml#scheduled
+
+# Daily Tests for Blazor
+# These use Sauce Labs resources, hence they run daily rather than per-commit.
+
+# We just need one Windows machine because all it does is trigger SauceLabs.
+variables:
+ SAUCE_CONNECT_DOWNLOAD_ON_INSTALL: true
+ E2ETESTS_SauceTest: true
+ E2ETESTS_Sauce__TunnelIdentifier: 'blazor-e2e-sc-proxy-tunnel'
+ E2ETESTS_Sauce__HostName: 'sauce.local'
+jobs:
+- template: jobs/default-build.yml
+ parameters:
+ buildDirectory: src/Components
+ isTestingJob: true
+ agentOs: Windows
+ jobName: BlazorDailyTests
+ jobDisplayName: "Blazor Daily Tests"
+ afterBuild:
+
+ # macOS/Safari
+ - script: 'dotnet test --filter "StandaloneAppTest"'
+ workingDirectory: 'src/Components/test/E2ETest'
+ displayName: 'Run Blazor tests - macOS/Safari'
+ condition: succeededOrFailed()
+ env:
+ # Secrets need to be explicitly mapped to env variables.
+ E2ETESTS_Sauce__Username: '$(asplab-sauce-labs-username)'
+ E2ETESTS_Sauce__AccessKey: '$(asplab-sauce-labs-access-key)'
+ # Set platform/browser configuration.
+ E2ETESTS_Sauce__TestName: 'Blazor Daily Tests - macOS/Safari'
+ E2ETESTS_Sauce__PlatformName: 'macOS 10.14'
+ E2ETESTS_Sauce__BrowserName: 'Safari'
+ # Need to explicitly set version here because some older versions don't support timeouts in Safari.
+ E2ETESTS_Sauce__SeleniumVersion: '3.4.0'
+
+ # Android/Chrome
+ - script: 'dotnet test --filter "StandaloneAppTest"'
+ workingDirectory: 'src/Components/test/E2ETest'
+ displayName: 'Run Blazor tests - Android/Chrome'
+ condition: succeededOrFailed()
+ env:
+ # Secrets need to be explicitly mapped to env variables.
+ E2ETESTS_Sauce__Username: '$(asplab-sauce-labs-username)'
+ E2ETESTS_Sauce__AccessKey: '$(asplab-sauce-labs-access-key)'
+ # Set platform/browser configuration.
+ E2ETESTS_Sauce__TestName: 'Blazor Daily Tests - Android/Chrome'
+ E2ETESTS_Sauce__PlatformName: 'Android'
+ E2ETESTS_Sauce__PlatformVersion: '10.0'
+ E2ETESTS_Sauce__BrowserName: 'Chrome'
+ E2ETESTS_Sauce__DeviceName: 'Android GoogleAPI Emulator'
+ E2ETESTS_Sauce__DeviceOrientation: 'portrait'
+ E2ETESTS_Sauce__AppiumVersion: '1.9.1'
+ artifacts:
+ - name: Windows_Logs
+ path: ../../artifacts/log/
+ publishOnError: true
\ No newline at end of file
diff --git a/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html b/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html
index fde34bd639..646ea2f3da 100644
--- a/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html
+++ b/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html
@@ -2,7 +2,8 @@
-
+
+
Blazor standalone
diff --git a/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs b/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs
index 9392d18aa9..4f2868269a 100644
--- a/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs
+++ b/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs
@@ -6,3 +6,4 @@ using Xunit;
[assembly: TestFramework("Microsoft.AspNetCore.E2ETesting.XunitTestFrameworkWithAssemblyFixture", "Microsoft.AspNetCore.Components.E2ETests")]
[assembly: AssemblyFixture(typeof(SeleniumStandaloneServer))]
+[assembly: AssemblyFixture(typeof(SauceConnectServer))]
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs
index 98ad897bb9..abb11bc748 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
+using Microsoft.AspNetCore.E2ETesting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
@@ -34,9 +35,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
var assembly = ApplicationAssembly ?? BuildWebHostMethod.Method.DeclaringType.Assembly;
var sampleSitePath = FindSampleOrTestSitePath(assembly.FullName);
+ var host = "127.0.0.1";
+ if (E2ETestOptions.Instance.SauceTest)
+ {
+ host = E2ETestOptions.Instance.Sauce.HostName;
+ }
+
return BuildWebHostMethod(new[]
{
- "--urls", "http://127.0.0.1:0",
+ "--urls", $"http://{host}:0",
"--contentroot", sampleSitePath,
"--environment", Environment.ToString(),
}.Concat(AdditionalArguments).ToArray());
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs
index d487598539..d09118ef3f 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs
@@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+using Microsoft.AspNetCore.E2ETesting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Http.Features;
@@ -24,9 +25,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
ContentRoot = FindSampleOrTestSitePath(
typeof(TProgram).Assembly.FullName);
+ var host = "127.0.0.1";
+ if (E2ETestOptions.Instance.SauceTest)
+ {
+ host = E2ETestOptions.Instance.Sauce.HostName;
+ }
+
var args = new List
{
- "--urls", "http://127.0.0.1:0",
+ "--urls", $"http://{host}:0",
"--contentroot", ContentRoot,
"--pathbase", PathBase,
"--applicationpath", typeof(TProgram).Assembly.Location,
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs
index 742a83f12f..67c164543c 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs
@@ -8,6 +8,7 @@ using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
+using Microsoft.AspNetCore.E2ETesting;
namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
{
@@ -22,7 +23,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
public ServerFixture()
{
_rootUriInitializer = new Lazy(() =>
- new Uri(StartAndGetRootUri()));
+ {
+ var uri = new Uri(StartAndGetRootUri());
+ if (E2ETestOptions.Instance.SauceTest)
+ {
+ uri = new UriBuilder(uri.Scheme, E2ETestOptions.Instance.Sauce.HostName, uri.Port).Uri;
+ }
+
+ return uri;
+ });
}
public abstract void Dispose();
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs
index 899f165e93..096e9315fe 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs
@@ -4,6 +4,7 @@
using System;
using System.IO;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.E2ETesting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
@@ -26,13 +27,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
var sampleSitePath = FindSampleOrTestSitePath(SampleSiteName);
+ var host = "127.0.0.1";
+ if (E2ETestOptions.Instance.SauceTest)
+ {
+ host = E2ETestOptions.Instance.Sauce.HostName;
+ }
+
return new HostBuilder()
.ConfigureWebHost(webHostBuilder => webHostBuilder
.UseKestrel()
.UseContentRoot(sampleSitePath)
.UseWebRoot(string.Empty)
.UseStartup()
- .UseUrls("http://127.0.0.1:0"))
+ .UseUrls($"http://{host}:0"))
.Build();
}
diff --git a/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs b/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs
index db665d64c0..4cc1f57cff 100644
--- a/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs
+++ b/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs
@@ -8,7 +8,6 @@ using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using System;
using System.Linq;
-using System.Threading.Tasks;
using Xunit;
using Xunit.Abstractions;
@@ -52,7 +51,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
// Verify we start at home, with the home link highlighted
Assert.Equal("Hello, world!", Browser.FindElement(mainHeaderSelector).Text);
Assert.Collection(Browser.FindElements(activeNavLinksSelector),
- item => Assert.Equal("Home", item.Text));
+ item => Assert.Equal("Home", item.Text.Trim()));
// Click on the "counter" link
Browser.FindElement(By.LinkText("Counter")).Click();
@@ -60,13 +59,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
// Verify we're now on the counter page, with that nav link (only) highlighted
Assert.Equal("Counter", Browser.FindElement(mainHeaderSelector).Text);
Assert.Collection(Browser.FindElements(activeNavLinksSelector),
- item => Assert.Equal("Counter", item.Text));
+ item => Assert.Equal("Counter", item.Text.Trim()));
// Verify we can navigate back to home too
Browser.FindElement(By.LinkText("Home")).Click();
Assert.Equal("Hello, world!", Browser.FindElement(mainHeaderSelector).Text);
Assert.Collection(Browser.FindElements(activeNavLinksSelector),
- item => Assert.Equal("Home", item.Text));
+ item => Assert.Equal("Home", item.Text.Trim()));
}
[Fact]
diff --git a/src/Components/test/E2ETest/e2eTestSettings.json b/src/Components/test/E2ETest/e2eTestSettings.json
index 809f33f046..1a7155db30 100644
--- a/src/Components/test/E2ETest/e2eTestSettings.json
+++ b/src/Components/test/E2ETest/e2eTestSettings.json
@@ -1,4 +1,4 @@
{
"DefaultWaitTimeoutInSeconds": 20,
- "ScreenShotsPath": "../../screenshots"
+ "ScreenShotsPath": "../../screenshots",
}
diff --git a/src/Components/test/E2ETest/package.json b/src/Components/test/E2ETest/package.json
index a84e769eb4..26a767f77b 100644
--- a/src/Components/test/E2ETest/package.json
+++ b/src/Components/test/E2ETest/package.json
@@ -6,11 +6,18 @@
"private": true,
"scripts": {
"selenium-standalone": "selenium-standalone",
- "prepare": "selenium-standalone install"
+ "prepare": "selenium-standalone install",
+ "sauce": "ts-node ./scripts/sauce.ts"
},
"author": "",
"license": "Apache-2.0",
"dependencies": {
+ "sauce-connect-launcher": "^1.3.1",
"selenium-standalone": "^6.15.4"
+ },
+ "devDependencies": {
+ "@types/node": "^13.1.7",
+ "ts-node": "^8.6.2",
+ "typescript": "^3.7.5"
}
}
diff --git a/src/Components/test/E2ETest/scripts/sauce.ts b/src/Components/test/E2ETest/scripts/sauce.ts
new file mode 100644
index 0000000000..395d0c1324
--- /dev/null
+++ b/src/Components/test/E2ETest/scripts/sauce.ts
@@ -0,0 +1,82 @@
+import { EOL } from "os";
+import * as _fs from "fs";
+import { promisify } from "util";
+
+// Promisify things from fs we want to use.
+const fs = {
+ createWriteStream: _fs.createWriteStream,
+ exists: promisify(_fs.exists),
+ mkdir: promisify(_fs.mkdir),
+ appendFile: promisify(_fs.appendFile),
+ readFile: promisify(_fs.readFile),
+};
+
+process.on("unhandledRejection", (reason) => {
+ console.error(`Unhandled promise rejection: ${reason}`);
+ process.exit(1);
+});
+
+let sauceUser = null;
+let sauceKey = null;
+let tunnelIdentifier = null;
+let hostName = null;
+
+for (let i = 0; i < process.argv.length; i += 1) {
+ switch (process.argv[i]) {
+ case "--sauce-user":
+ i += 1;
+ sauceUser = process.argv[i];
+ break;
+ case "--sauce-key":
+ i += 1;
+ sauceKey = process.argv[i];
+ break;
+ case "--sauce-tunnel":
+ i += 1;
+ tunnelIdentifier = process.argv[i];
+ break;
+ case "--use-hostname":
+ i += 1;
+ hostName = process.argv[i];
+ break;
+ }
+}
+
+const HOSTSFILE_PATH = process.platform === "win32" ? `${process.env.SystemRoot}\\System32\\drivers\\etc\\hosts` : null;
+
+(async () => {
+
+ if (hostName) {
+ // Register a custom hostname in the hosts file (requires Admin, but AzDO agents run as Admin)
+ // Used to work around issues in Sauce Labs.
+ if (process.platform !== "win32") {
+ throw new Error("Can't use '--use-hostname' on non-Windows platform.");
+ }
+
+ try {
+
+ console.log(`Updating Hosts file (${HOSTSFILE_PATH}) to register host name '${hostName}'`);
+ await fs.appendFile(HOSTSFILE_PATH, `${EOL}127.0.0.1 ${hostName}${EOL}`);
+
+ } catch (error) {
+ console.log(`Unable to update hosts file at ${HOSTSFILE_PATH}. Error: ${error}`);
+ }
+ }
+
+
+ // Creates a persistent proxy tunnel using Sauce Connect.
+ var sauceConnectLauncher = require('sauce-connect-launcher');
+
+ sauceConnectLauncher({
+ username: sauceUser,
+ accessKey: sauceKey,
+ tunnelIdentifier: tunnelIdentifier,
+ }, function (err, sauceConnectProcess) {
+ if (err) {
+ console.error(err.message);
+ return;
+ }
+
+ console.log("Sauce Connect ready");
+ });
+})();
diff --git a/src/Components/test/E2ETest/yarn.lock b/src/Components/test/E2ETest/yarn.lock
index 6f2b1cc3e3..250aa50786 100644
--- a/src/Components/test/E2ETest/yarn.lock
+++ b/src/Components/test/E2ETest/yarn.lock
@@ -2,6 +2,23 @@
# yarn lockfile v1
+"@types/node@^13.1.7":
+ version "13.1.8"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-13.1.8.tgz#1d590429fe8187a02707720ecf38a6fe46ce294b"
+ integrity sha512-6XzyyNM9EKQW4HKuzbo/CkOIjn/evtCmsU+MUM1xDfJ+3/rNjBttM1NgN7AOQvN6tP1Sl1D1PIKMreTArnxM9A==
+
+adm-zip@~0.4.3:
+ version "0.4.13"
+ resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
+ integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+
+agent-base@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+ integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+ dependencies:
+ es6-promisify "^5.0.0"
+
ajv@^6.5.5:
version "6.10.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
@@ -12,6 +29,11 @@ ajv@^6.5.5:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
+arg@^4.1.0:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.2.tgz#e70c90579e02c63d80e3ad4e31d8bfdb8bd50064"
+ integrity sha512-+ytCkGcBtHZ3V2r2Z06AncYO8jz46UEamcspGoU8lHcEbpn6J77QK0vdWvChsclg/tM5XIJC5tnjmPp7Eq6Obg==
+
asn1@~0.2.3:
version "0.2.4"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -24,7 +46,7 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
-async@^2.6.2:
+async@^2.1.2, async@^2.6.2:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
@@ -46,6 +68,11 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+balanced-match@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+ integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+
bcrypt-pbkdf@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
@@ -61,11 +88,24 @@ bl@^2.2.0:
readable-stream "^2.3.5"
safe-buffer "^5.1.1"
+brace-expansion@^1.1.7:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+ integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+ dependencies:
+ balanced-match "^1.0.0"
+ concat-map "0.0.1"
+
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+buffer-from@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+ integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -83,6 +123,11 @@ commander@^2.19.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
+concat-map@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+ integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -106,6 +151,13 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
+debug@^3.1.0:
+ version "3.2.6"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+ integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+ dependencies:
+ ms "^2.1.1"
+
debug@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
@@ -118,6 +170,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+diff@^4.0.1:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
+ integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+
ecc-jsbn@~0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@@ -133,6 +190,18 @@ end-of-stream@^1.4.1:
dependencies:
once "^1.4.0"
+es6-promise@^4.0.3:
+ version "4.2.8"
+ resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+ integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+ integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+ dependencies:
+ es6-promise "^4.0.3"
+
extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
@@ -184,6 +253,11 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+fs.realpath@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+ integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -191,6 +265,18 @@ getpass@^0.1.1:
dependencies:
assert-plus "^1.0.0"
+glob@^7.1.3:
+ version "7.1.6"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+ integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.0.4"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
har-schema@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
@@ -213,7 +299,23 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
-inherits@^2.0.3, inherits@~2.0.3:
+https-proxy-agent@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
+ integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
+ dependencies:
+ agent-base "^4.3.0"
+ debug "^3.1.0"
+
+inflight@^1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+ integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+ dependencies:
+ once "^1.3.0"
+ wrappy "1"
+
+inherits@2, inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -268,11 +370,21 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
+lodash@^4.16.6:
+ version "4.17.15"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+ integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
lodash@^4.17.11, lodash@^4.17.14:
version "4.17.14"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
+make-error@^1.1.1:
+ version "1.3.5"
+ resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8"
+ integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==
+
mime-db@1.40.0:
version "1.40.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
@@ -285,6 +397,13 @@ mime-types@^2.1.12, mime-types@~2.1.19:
dependencies:
mime-db "1.40.0"
+minimatch@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+ integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+ dependencies:
+ brace-expansion "^1.1.7"
+
minimist@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
@@ -317,13 +436,18 @@ oauth-sign@~0.9.0:
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-once@^1.4.0:
+once@^1.3.0, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
dependencies:
wrappy "1"
+path-is-absolute@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+ integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
path-key@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
@@ -417,6 +541,13 @@ request@2.88.0:
tunnel-agent "^0.6.0"
uuid "^3.3.2"
+rimraf@^2.5.4:
+ version "2.7.1"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+ integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+ dependencies:
+ glob "^7.1.3"
+
safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2:
version "5.2.0"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
@@ -432,6 +563,17 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+sauce-connect-launcher@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
+ integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
+ dependencies:
+ adm-zip "~0.4.3"
+ async "^2.1.2"
+ https-proxy-agent "^3.0.0"
+ lodash "^4.16.6"
+ rimraf "^2.5.4"
+
selenium-standalone@^6.15.4:
version "6.16.0"
resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.16.0.tgz#ffcf02665c58ff7a7472427ae819ba79c15967ac"
@@ -468,6 +610,19 @@ shebang-regex@^1.0.0:
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
+source-map-support@^0.5.6:
+ version "0.5.16"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042"
+ integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==
+ dependencies:
+ buffer-from "^1.0.0"
+ source-map "^0.6.0"
+
+source-map@^0.6.0:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+ integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
sshpk@^1.7.0:
version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@@ -516,6 +671,17 @@ tough-cookie@~2.4.3:
psl "^1.1.24"
punycode "^1.4.1"
+ts-node@^8.6.2:
+ version "8.6.2"
+ resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.6.2.tgz#7419a01391a818fbafa6f826a33c1a13e9464e35"
+ integrity sha512-4mZEbofxGqLL2RImpe3zMJukvEvcO1XP8bj8ozBPySdCUXEcU5cIRwR0aM3R+VoZq7iXc8N86NC0FspGRqP4gg==
+ dependencies:
+ arg "^4.1.0"
+ diff "^4.0.1"
+ make-error "^1.1.1"
+ source-map-support "^0.5.6"
+ yn "3.1.1"
+
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@@ -528,6 +694,11 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+typescript@^3.7.5:
+ version "3.7.5"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
+ integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+
uri-js@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
@@ -578,3 +749,8 @@ yauzl@^2.10.0:
dependencies:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
+
+yn@3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
+ integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
diff --git a/src/Shared/E2ETesting/BrowserFixture.cs b/src/Shared/E2ETesting/BrowserFixture.cs
index 7808176318..bf8b31d294 100644
--- a/src/Shared/E2ETesting/BrowserFixture.cs
+++ b/src/Shared/E2ETesting/BrowserFixture.cs
@@ -72,13 +72,24 @@ namespace Microsoft.AspNetCore.E2ETesting
public Task<(IWebDriver, ILogs)> GetOrCreateBrowserAsync(ITestOutputHelper output, string isolationContext = "")
{
- if (!IsHostAutomationSupported())
+ Func> createBrowserFunc;
+ if (E2ETestOptions.Instance.SauceTest)
{
- output.WriteLine($"{nameof(BrowserFixture)}: Host does not support browser automation.");
- return Task.FromResult<(IWebDriver, ILogs)>(default);
+ createBrowserFunc = CreateSauceBrowserAsync;
+ }
+ else
+ {
+ if (!IsHostAutomationSupported())
+ {
+ output.WriteLine($"{nameof(BrowserFixture)}: Host does not support browser automation.");
+ return Task.FromResult<(IWebDriver, ILogs)>(default);
+ }
+
+ createBrowserFunc = CreateBrowserAsync;
}
- return _browsers.GetOrAdd(isolationContext, CreateBrowserAsync, output);
+
+ return _browsers.GetOrAdd(isolationContext, createBrowserFunc, output);
}
public Task InitializeAsync() => Task.CompletedTask;
@@ -143,5 +154,106 @@ namespace Microsoft.AspNetCore.E2ETesting
throw new InvalidOperationException("Couldn't create a Selenium remote driver client. The server is irresponsive");
}
+
+ private async Task<(IWebDriver browser, ILogs log)> CreateSauceBrowserAsync(string context, ITestOutputHelper output)
+ {
+ var sauce = E2ETestOptions.Instance.Sauce;
+
+ if (sauce == null ||
+ string.IsNullOrEmpty(sauce.TestName) ||
+ string.IsNullOrEmpty(sauce.Username) ||
+ string.IsNullOrEmpty(sauce.AccessKey) ||
+ string.IsNullOrEmpty(sauce.TunnelIdentifier) ||
+ string.IsNullOrEmpty(sauce.PlatformName) ||
+ string.IsNullOrEmpty(sauce.BrowserName))
+ {
+ throw new InvalidOperationException("Required SauceLabs environment variables not set.");
+ }
+
+ var name = sauce.TestName;
+ if (!string.IsNullOrEmpty(context))
+ {
+ name = $"{name} - {context}";
+ }
+
+ var capabilities = new DesiredCapabilities();
+
+ // Required config
+ capabilities.SetCapability("username", sauce.Username);
+ capabilities.SetCapability("accessKey", sauce.AccessKey);
+ capabilities.SetCapability("tunnelIdentifier", sauce.TunnelIdentifier);
+ capabilities.SetCapability("name", name);
+
+ if (!string.IsNullOrEmpty(sauce.BrowserName))
+ {
+ capabilities.SetCapability("browserName", sauce.BrowserName);
+ }
+
+ if (!string.IsNullOrEmpty(sauce.PlatformVersion))
+ {
+ capabilities.SetCapability("platformName", sauce.PlatformName);
+ capabilities.SetCapability("platformVersion", sauce.PlatformVersion);
+ }
+ else
+ {
+ // In some cases (like macOS), SauceLabs expects us to set "platform" instead of "platformName".
+ capabilities.SetCapability("platform", sauce.PlatformName);
+ }
+
+ if (!string.IsNullOrEmpty(sauce.BrowserVersion))
+ {
+ capabilities.SetCapability("browserVersion", sauce.BrowserVersion);
+ }
+
+ if (!string.IsNullOrEmpty(sauce.DeviceName))
+ {
+ capabilities.SetCapability("deviceName", sauce.DeviceName);
+ }
+
+ if (!string.IsNullOrEmpty(sauce.DeviceOrientation))
+ {
+ capabilities.SetCapability("deviceOrientation", sauce.DeviceOrientation);
+ }
+
+ if (!string.IsNullOrEmpty(sauce.AppiumVersion))
+ {
+ capabilities.SetCapability("appiumVersion", sauce.AppiumVersion);
+ }
+
+ if (!string.IsNullOrEmpty(sauce.SeleniumVersion))
+ {
+ capabilities.SetCapability("seleniumVersion", sauce.SeleniumVersion);
+ }
+
+ await SauceConnectServer.StartAsync(output);
+
+ var attempt = 0;
+ const int maxAttempts = 3;
+ do
+ {
+ try
+ {
+ // Attempt to create a new browser in SauceLabs.
+ var driver = new RemoteWebDriver(
+ new Uri("http://localhost:4445/wd/hub"),
+ capabilities,
+ TimeSpan.FromSeconds(60).Add(TimeSpan.FromSeconds(attempt * 60)));
+
+ driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(1);
+ var logs = new RemoteLogs(driver);
+
+ return (driver, logs);
+ }
+ catch (Exception ex)
+ {
+ output.WriteLine($"Error initializing RemoteWebDriver: {ex.Message}");
+ }
+
+ attempt++;
+
+ } while (attempt < maxAttempts);
+
+ throw new InvalidOperationException("Couldn't create a SauceLabs remote driver client.");
+ }
}
}
diff --git a/src/Shared/E2ETesting/E2ETestOptions.cs b/src/Shared/E2ETesting/E2ETestOptions.cs
index 0b35320176..4be16ef779 100644
--- a/src/Shared/E2ETesting/E2ETestOptions.cs
+++ b/src/Shared/E2ETesting/E2ETestOptions.cs
@@ -39,7 +39,7 @@ namespace Microsoft.AspNetCore.E2ETesting
}
Configuration = builder
- .AddEnvironmentVariables("E2ETESTS")
+ .AddEnvironmentVariables("E2ETESTS_")
.Build();
var instance = new E2ETestOptions();
@@ -56,5 +56,9 @@ namespace Microsoft.AspNetCore.E2ETesting
public string ScreenShotsPath { get; set; }
public double DefaultAfterFailureWaitTimeoutInSeconds { get; set; } = 3;
+
+ public bool SauceTest { get; set; }
+
+ public SauceOptions Sauce { get; set; }
}
}
diff --git a/src/Shared/E2ETesting/E2ETesting.props b/src/Shared/E2ETesting/E2ETesting.props
index e31cbd93ac..6433411060 100644
--- a/src/Shared/E2ETesting/E2ETesting.props
+++ b/src/Shared/E2ETesting/E2ETesting.props
@@ -5,6 +5,7 @@
$([MSBuild]::NormalizeDirectory('$(ArtifactsTestResultsDir)','$(MSBuildProjectName)'))
$([MSBuild]::EnsureTrailingSlash('$(RepoRoot)'))artifacts\tmp\selenium\
true
+ $([MSBuild]::EnsureTrailingSlash('$(RepoRoot)'))artifacts\tmp\sauceconnect\
true
diff --git a/src/Shared/E2ETesting/E2ETesting.targets b/src/Shared/E2ETesting/E2ETesting.targets
index 500d910a30..1cb421de3b 100644
--- a/src/Shared/E2ETesting/E2ETesting.targets
+++ b/src/Shared/E2ETesting/E2ETesting.targets
@@ -121,6 +121,14 @@
<_Parameter2>$(SeleniumProcessTrackingFolder)
+
+
+
+ <_Parameter1>Microsoft.AspNetCore.Testing.SauceConnect.ProcessTracking
+ <_Parameter2>$(SauceConnectProcessTrackingFolder)
+
+
diff --git a/src/Shared/E2ETesting/SauceConnectServer.cs b/src/Shared/E2ETesting/SauceConnectServer.cs
new file mode 100644
index 0000000000..ac53b9b472
--- /dev/null
+++ b/src/Shared/E2ETesting/SauceConnectServer.cs
@@ -0,0 +1,262 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.E2ETesting;
+using Microsoft.Extensions.Internal;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+
+namespace Microsoft.AspNetCore.E2ETesting
+{
+ public class SauceConnectServer : IDisposable
+ {
+ private static SemaphoreSlim _semaphore = new SemaphoreSlim(1);
+
+ private Process _process;
+ private string _sentinelPath;
+ private Process _sentinelProcess;
+ private static IMessageSink _diagnosticsMessageSink;
+
+ // 2h
+ private static int SauceConnectProcessTimeout = 7200;
+
+ public SauceConnectServer(IMessageSink diagnosticsMessageSink)
+ {
+ if (Instance != null || _diagnosticsMessageSink != null)
+ {
+ throw new InvalidOperationException("Sauce connect singleton already created.");
+ }
+
+ // The assembly level attribute AssemblyFixture takes care of this being being instantiated before tests run
+ // and disposed after tests are run, gracefully shutting down the server when possible by calling Dispose on
+ // the singleton.
+ Instance = this;
+ _diagnosticsMessageSink = diagnosticsMessageSink;
+ }
+
+ private void Initialize(
+ Process process,
+ string sentinelPath,
+ Process sentinelProcess)
+ {
+ _process = process;
+ _sentinelPath = sentinelPath;
+ _sentinelProcess = sentinelProcess;
+ }
+
+ internal static SauceConnectServer Instance { get; private set; }
+
+ public static async Task StartAsync(ITestOutputHelper output)
+ {
+ try
+ {
+ await _semaphore.WaitAsync();
+ if (Instance._process == null)
+ {
+ // No process was started, meaning the instance wasn't initialized.
+ await InitializeInstance(output);
+ }
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ private static async Task InitializeInstance(ITestOutputHelper output)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "npm",
+ Arguments = "run sauce --" +
+ $" --sauce-user {E2ETestOptions.Instance.Sauce.Username}" +
+ $" --sauce-key {E2ETestOptions.Instance.Sauce.AccessKey}" +
+ $" --sauce-tunnel {E2ETestOptions.Instance.Sauce.TunnelIdentifier}" +
+ $" --use-hostname {E2ETestOptions.Instance.Sauce.HostName}",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ psi.FileName = "cmd";
+ psi.Arguments = $"/c npm {psi.Arguments}";
+ }
+
+ // It's important that we get the folder value before we start the process to prevent
+ // untracked processes when the tracking folder is not correctly configure.
+ var trackingFolder = GetProcessTrackingFolder();
+ if (!Directory.Exists(trackingFolder))
+ {
+ throw new InvalidOperationException($"Invalid tracking folder. Set the 'SauceConnectProcessTrackingFolder' MSBuild property to a valid folder.");
+ }
+
+ Process process = null;
+ Process sentinel = null;
+ string pidFilePath = null;
+ try
+ {
+ process = Process.Start(psi);
+ pidFilePath = await WriteTrackingFileAsync(output, trackingFolder, process);
+ sentinel = StartSentinelProcess(process, pidFilePath, SauceConnectProcessTimeout);
+ }
+ catch
+ {
+ ProcessCleanup(process, pidFilePath);
+ ProcessCleanup(sentinel, pidFilePath: null);
+ throw;
+ }
+
+ // Log output for sauce connect process.
+ // This is for the case where the server fails to launch.
+ var logOutput = new BlockingCollection();
+
+ process.OutputDataReceived += LogOutput;
+ process.ErrorDataReceived += LogOutput;
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ // The Sauce connect server has to be up for the entirety of the tests and is only shutdown when the application (i.e. the test) exits.
+ AppDomain.CurrentDomain.ProcessExit += (sender, args) => ProcessCleanup(process, pidFilePath);
+
+ // Log
+ void LogOutput(object sender, DataReceivedEventArgs e)
+ {
+ logOutput.TryAdd(e.Data);
+
+ // We avoid logging on the output here because it is unreliable. We can only log in the diagnostics sink.
+ lock (_diagnosticsMessageSink)
+ {
+ _diagnosticsMessageSink.OnMessage(new DiagnosticMessage(e.Data));
+ }
+ }
+
+ var uri = new UriBuilder("http", E2ETestOptions.Instance.Sauce.HostName, 4445).Uri;
+ var httpClient = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(1),
+ };
+
+ var retries = 0;
+ do
+ {
+ await Task.Delay(1000);
+ try
+ {
+ var response = await httpClient.GetAsync(uri);
+ if (response.StatusCode == HttpStatusCode.OK)
+ {
+ output = null;
+ Instance.Initialize(process, pidFilePath, sentinel);
+ return;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (HttpRequestException)
+ {
+ }
+
+ retries++;
+ } while (retries < 30);
+
+ // Make output null so that we stop logging to it.
+ output = null;
+ logOutput.CompleteAdding();
+ var exitCodeString = process.HasExited ? process.ExitCode.ToString() : "Process has not yet exited.";
+ var message = $@"Failed to launch the server.
+ExitCode: {exitCodeString}
+Captured output lines:
+{string.Join(Environment.NewLine, logOutput.GetConsumingEnumerable())}.";
+
+ // If we got here, we couldn't launch Sauce connect or get it to respond. So shut it down.
+ ProcessCleanup(process, pidFilePath);
+ throw new InvalidOperationException(message);
+ }
+
+ private static Process StartSentinelProcess(Process process, string sentinelFile, int timeout)
+ {
+ // This sentinel process will start and will kill any rouge sauce connect server that wasn't torn down via normal means.
+ var psi = new ProcessStartInfo
+ {
+ FileName = "powershell",
+ Arguments = $"-NoProfile -NonInteractive -Command \"Start-Sleep {timeout}; " +
+ $"if(Test-Path {sentinelFile}){{ " +
+ $"Write-Output 'Stopping process {process.Id}'; Stop-Process {process.Id}; }}" +
+ $"else{{ Write-Output 'Sentinel file {sentinelFile} not found.'}}",
+ };
+
+ return Process.Start(psi);
+ }
+
+ private static void ProcessCleanup(Process process, string pidFilePath)
+ {
+ try
+ {
+ if (process?.HasExited == false)
+ {
+ try
+ {
+ process?.KillTree(TimeSpan.FromSeconds(10));
+ process?.Dispose();
+ }
+ catch
+ {
+ // Ignore errors here since we can't do anything
+ }
+ }
+ if (pidFilePath != null && File.Exists(pidFilePath))
+ {
+ File.Delete(pidFilePath);
+ }
+ }
+ catch
+ {
+ // Ignore errors here since we can't do anything
+ }
+ }
+
+ private static async Task WriteTrackingFileAsync(ITestOutputHelper output, string trackingFolder, Process process)
+ {
+ var pidFile = Path.Combine(trackingFolder, $"{process.Id}.{Guid.NewGuid()}.pid");
+ for (var i = 0; i < 3; i++)
+ {
+ try
+ {
+ await File.WriteAllTextAsync(pidFile, process.Id.ToString());
+ return pidFile;
+ }
+ catch
+ {
+ output.WriteLine($"Can't write file to process tracking folder: {trackingFolder}");
+ }
+ }
+
+ throw new InvalidOperationException($"Failed to write file for process {process.Id}");
+ }
+
+ private static string GetProcessTrackingFolder() =>
+ typeof(SauceConnectServer).Assembly
+ .GetCustomAttributes()
+ .Single(a => a.Key == "Microsoft.AspNetCore.Testing.SauceConnect.ProcessTracking").Value;
+
+ public void Dispose()
+ {
+ ProcessCleanup(_process, _sentinelPath);
+ ProcessCleanup(_sentinelProcess, pidFilePath: null);
+ }
+ }
+}
diff --git a/src/Shared/E2ETesting/SauceOptions.cs b/src/Shared/E2ETesting/SauceOptions.cs
new file mode 100644
index 0000000000..b8bb22ec8c
--- /dev/null
+++ b/src/Shared/E2ETesting/SauceOptions.cs
@@ -0,0 +1,36 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.E2ETesting
+{
+ public class SauceOptions
+ {
+ public string Username { get; set; }
+
+ public string AccessKey { get; set; }
+
+ public string TunnelIdentifier { get; set; }
+
+ public string HostName { get; set; }
+
+ public string TestName { get; set; }
+
+ public bool IsRealDevice { get; set; }
+
+ public string PlatformName { get; set; }
+
+ public string PlatformVersion { get; set; }
+
+ public string BrowserName { get; set; }
+
+ public string BrowserVersion { get; set; }
+
+ public string DeviceName { get; set; }
+
+ public string DeviceOrientation { get; set; }
+
+ public string AppiumVersion { get; set; }
+
+ public string SeleniumVersion { get; set; }
+ }
+}