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; } + } +}