File: src\VisualStudio\IntegrationTest\Harness\XUnitShared\Harness\VisualStudioInstanceFactory.cs
Web Access
Project: src\src\VisualStudio\IntegrationTest\Harness\XUnit\Microsoft.VisualStudio.Extensibility.Testing.Xunit.csproj (Microsoft.VisualStudio.Extensibility.Testing.Xunit)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
namespace Xunit.Harness
{
    using System;
    using System.Collections.Generic;
    using System.Collections.Immutable;
    using System.Diagnostics;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.VisualStudio.Setup.Configuration;
    using Microsoft.Win32;
    using DTE = EnvDTE.DTE;
    using File = System.IO.File;
    using Path = System.IO.Path;
 
    internal sealed class VisualStudioInstanceFactory : MarshalByRefObject, IDisposable
    {
        private const int ReportTimeMinute = 5;
        private static readonly TimeSpan ReportTimeInterval = TimeSpan.FromMinutes(ReportTimeMinute);
        private readonly bool _leaveRunning;
 
        /// <summary>
        /// The instance that has already been launched by this factory and can be reused.
        /// </summary>
        private VisualStudioInstance? _currentlyRunningInstance;
 
        private bool _hasCurrentlyActiveContext;
 
        public VisualStudioInstanceFactory(bool leaveRunning = false)
        {
            Debug.WriteLine($"Initialize");
            if (Process.GetCurrentProcess().ProcessName != "devenv")
            {
                AppDomain.CurrentDomain.AssemblyResolve += AssemblyResolveHandler;
            }
 
            _leaveRunning = leaveRunning;
        }
 
        // This looks like it is pointless (since we are returning an assembly that is already loaded) but it is actually required.
        // The BinaryFormatter, when invoking 'HandleReturnMessage', will end up attempting to call 'BinaryAssemblyInfo.GetAssembly()',
        // which will itself attempt to call 'Assembly.Load()' using the full name of the assembly for the type that is being deserialized.
        // Depending on the manner in which the assembly was originally loaded, this may end up actually trying to load the assembly a second
        // time and it can fail if the standard assembly resolution logic fails. This ensures that we 'succeed' this secondary load by returning
        // the assembly that is already loaded.
        internal static Assembly? AssemblyResolveHandler(object sender, ResolveEventArgs eventArgs)
        {
            Debug.WriteLine($"'{eventArgs.RequestingAssembly}' is attempting to resolve '{eventArgs.Name}'");
            var resolvedAssembly = AppDomain.CurrentDomain.GetAssemblies().Where((assembly) => assembly.FullName.Equals(eventArgs.Name)).SingleOrDefault();
 
            if (resolvedAssembly != null)
            {
                Debug.WriteLine("The assembly was already loaded!");
            }
 
            // Support resolving embedded assemblies
            using (var assemblyStream = typeof(VisualStudioInstanceFactory).Assembly.GetManifestResourceStream(new AssemblyName(eventArgs.Name).Name + ".dll"))
            using (var memoryStream = new MemoryStream())
            {
                if (assemblyStream != null)
                {
                    assemblyStream.CopyTo(memoryStream);
                    return Assembly.Load(memoryStream.ToArray());
                }
            }
 
            return resolvedAssembly;
        }
 
        /// <summary>
        /// Returns a <see cref="VisualStudioInstanceContext"/>, starting a new instance of Visual Studio if necessary.
        /// </summary>
        public async Task<VisualStudioInstanceContext> GetNewOrUsedInstanceAsync(Version version, string? rootSuffix, ImmutableDictionary<string, string> environmentVariables, ImmutableList<string> extensionFiles, ImmutableHashSet<string> requiredPackageIds)
        {
            ThrowExceptionIfAlreadyHasActiveContext();
 
            var shouldStartNewInstance = ShouldStartNewInstance(version, requiredPackageIds);
            await UpdateCurrentlyRunningInstanceAsync(version, rootSuffix, environmentVariables, extensionFiles, requiredPackageIds, shouldStartNewInstance).ConfigureAwait(false);
 
            return new VisualStudioInstanceContext(_currentlyRunningInstance!, this);
        }
 
        internal void NotifyCurrentInstanceContextDisposed(bool canReuse)
        {
            ThrowExceptionIfAlreadyHasActiveContext();
 
            _hasCurrentlyActiveContext = false;
 
            if (!canReuse)
            {
                _currentlyRunningInstance?.Close();
                _currentlyRunningInstance = null;
            }
        }
 
        private bool ShouldStartNewInstance(Version version, ImmutableHashSet<string> requiredPackageIds)
        {
            // We need to start a new instance if:
            //  * The current instance does not exist -or-
            //  * The current instance is not the correct version -or-
            //  * The current instance does not support all the required packages -or-
            //  * The current instance is no longer running
            return _currentlyRunningInstance == null
                || _currentlyRunningInstance.Version.Major != version.Major
                || (!requiredPackageIds.All(id => _currentlyRunningInstance.SupportedPackageIds.Contains(id)))
                || !_currentlyRunningInstance.IsRunning;
        }
 
        private void ThrowExceptionIfAlreadyHasActiveContext()
        {
            if (_hasCurrentlyActiveContext)
            {
                throw new Exception($"The previous integration test failed to call {nameof(VisualStudioInstanceContext)}.{nameof(Dispose)}. Ensure that test does that to ensure the Visual Studio instance is correctly cleaned up.");
            }
        }
 
        /// <summary>
        /// Starts up a new <see cref="VisualStudioInstance"/>, shutting down any instances that are already running.
        /// </summary>
        private async Task UpdateCurrentlyRunningInstanceAsync(Version version, string? rootSuffix, ImmutableDictionary<string, string> environmentVariables, ImmutableList<string> extensionFiles, ImmutableHashSet<string> requiredPackageIds, bool shouldStartNewInstance)
        {
            Process hostProcess;
            DTE dte;
            Version actualVersion;
            ImmutableHashSet<string> supportedPackageIds;
            string installationPath;
            string instanceId;
 
            if (shouldStartNewInstance)
            {
                // We are starting a new instance, so ensure we close the currently running instance, if it exists
                _currentlyRunningInstance?.Close();
 
                var instance = LocateVisualStudioInstance(version, requiredPackageIds);
                supportedPackageIds = instance.Item3;
                installationPath = instance.Item1;
                actualVersion = instance.Item2;
                instanceId = instance.Item5;
 
                hostProcess = await StartNewVisualStudioProcessAsync(installationPath, version, rootSuffix, environmentVariables, extensionFiles, instanceId).ConfigureAwait(true);
 
                // We wait until the DTE instance is up before we're good
                dte = await IntegrationHelper.WaitForNotNullAsync(() => IntegrationHelper.TryLocateDteForProcess(hostProcess)).ConfigureAwait(true);
            }
            else
            {
                // We are going to reuse the currently running instance, so ensure that we grab the host Process and Dte
                // before cleaning up any hooks or remoting services created by the previous instance. We will then
                // create a new VisualStudioInstance from the previous to ensure that everything is in a 'clean' state.
                //
                // We create a new DTE instance in the current context since the COM object could have been separated
                // from its RCW during the previous test.
                Debug.Assert(_currentlyRunningInstance != null, "Assertion failed: _currentlyRunningInstance != null");
 
                hostProcess = _currentlyRunningInstance!.HostProcess;
                dte = await IntegrationHelper.WaitForNotNullAsync(() => IntegrationHelper.TryLocateDteForProcess(hostProcess)).ConfigureAwait(true);
                actualVersion = _currentlyRunningInstance.Version;
                supportedPackageIds = _currentlyRunningInstance.SupportedPackageIds;
                installationPath = _currentlyRunningInstance.InstallationPath;
 
                _currentlyRunningInstance.Close(exitHostProcess: false);
            }
 
            _currentlyRunningInstance = new VisualStudioInstance(hostProcess, dte, actualVersion, supportedPackageIds, installationPath);
            if (shouldStartNewInstance)
            {
                var harnessAssemblyDirectory = Path.GetDirectoryName(typeof(VisualStudioInstanceFactory).Assembly.CodeBase);
                if (harnessAssemblyDirectory.StartsWith("file:"))
                {
                    harnessAssemblyDirectory = new Uri(harnessAssemblyDirectory).LocalPath;
                }
 
                _currentlyRunningInstance.AddCodeBaseDirectory(harnessAssemblyDirectory);
            }
        }
 
        internal static IEnumerable<Tuple<string, Version, ImmutableHashSet<string>, InstanceState, string>> EnumerateVisualStudioInstances()
        {
            foreach (ISetupInstance2 result in EnumerateVisualStudioInstancesViaInstaller())
            {
                var productDir = Path.GetFullPath(result.GetInstallationPath());
                var version = Version.Parse(result.GetInstallationVersion());
                var supportedPackageIds = ImmutableHashSet.CreateRange(result.GetPackages().Select(package => package.GetId()));
                var instanceId = result.GetInstanceId();
                yield return Tuple.Create(productDir, version, supportedPackageIds, result.GetState(), instanceId);
            }
        }
 
        private static IEnumerable<ISetupInstance> EnumerateVisualStudioInstancesViaInstaller()
        {
            var setupConfiguration = new SetupConfiguration();
 
            var instanceEnumerator = setupConfiguration.EnumAllInstances();
            var instances = new ISetupInstance[3];
 
            instanceEnumerator.Next(instances.Length, instances, out var instancesFetched);
 
            do
            {
                for (var index = 0; index < instancesFetched; index++)
                {
                    yield return instances[index];
                }
 
                instanceEnumerator.Next(instances.Length, instances, out instancesFetched);
            }
            while (instancesFetched != 0);
        }
 
        private static Tuple<string, Version, ImmutableHashSet<string>, InstanceState, string> LocateVisualStudioInstance(Version version, ImmutableHashSet<string> requiredPackageIds)
        {
            var vsInstallDir = Environment.GetEnvironmentVariable("__UNITTESTEXPLORER_VSINSTALLPATH__")
                ?? Environment.GetEnvironmentVariable("VSAPPIDDIR");
            if (vsInstallDir != null)
            {
                vsInstallDir = Path.GetFullPath(Path.Combine(vsInstallDir, @"..\.."));
            }
            else
            {
                vsInstallDir = Environment.GetEnvironmentVariable("VSInstallDir");
            }
 
            var haveVsInstallDir = !string.IsNullOrEmpty(vsInstallDir);
            if (haveVsInstallDir)
            {
                // Only use vsInstallDir if its version is compatible with the desired test version
                vsInstallDir = Path.GetFullPath(vsInstallDir);
                vsInstallDir = vsInstallDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 
                var candidateInstance = EnumerateVisualStudioInstances().FirstOrDefault(instance =>
                {
                    var installationPath = instance.Item1;
                    installationPath = Path.GetFullPath(installationPath);
                    installationPath = installationPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
                    return installationPath.Equals(vsInstallDir, StringComparison.OrdinalIgnoreCase);
                });
 
                if (candidateInstance == null || candidateInstance.Item2.Major != version.Major)
                {
                    vsInstallDir = null;
                    haveVsInstallDir = false;
                }
                else
                {
                    Debug.WriteLine($"An environment variable named 'VSInstallDir' (or equivalent) was found, adding this to the specified requirements. (VSInstallDir: {vsInstallDir})");
                }
            }
 
            var instances = EnumerateVisualStudioInstances().Where((instance) =>
            {
                var isMatch = true;
                {
                    isMatch &= version.Major == instance.Item2.Major;
                    isMatch &= instance.Item2 >= version;
 
                    if (haveVsInstallDir && version.Major >= 15)
                    {
                        var installationPath = instance.Item1;
                        installationPath = Path.GetFullPath(installationPath);
                        installationPath = installationPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
                        isMatch &= installationPath.Equals(vsInstallDir, StringComparison.OrdinalIgnoreCase);
                    }
                }
 
                return isMatch;
            });
 
            var instanceFoundWithInvalidState = false;
 
            foreach (var instance in instances)
            {
                var packages = instance.Item3.Where(package => requiredPackageIds.Contains(package));
                if (packages.Count() != requiredPackageIds.Count())
                {
                    continue;
                }
 
                const InstanceState minimumRequiredState = InstanceState.Local | InstanceState.Registered;
 
                var state = instance.Item4;
 
                if ((state & minimumRequiredState) == minimumRequiredState)
                {
                    return instance;
                }
 
                Debug.WriteLine($"An instance matching the specified requirements but had an invalid state. (State: {state})");
                instanceFoundWithInvalidState = true;
            }
 
            throw new PlatformNotSupportedException(instanceFoundWithInvalidState
                                ? "An instance matching the specified requirements was found but it was in an invalid state."
                                : "There were no instances of Visual Studio found that match the specified requirements.");
        }
 
        private static async Task<Process> StartNewVisualStudioProcessAsync(string installationPath, Version version, string? rootSuffix, ImmutableDictionary<string, string> environmentVariables, ImmutableList<string> extensionFiles, string vsInstanceId)
        {
            var vsExeFile = Path.Combine(installationPath, @"Common7\IDE\devenv.exe");
            var vsRegEditExeFile = Path.Combine(installationPath, @"Common7\IDE\VsRegEdit.exe");
            var vsixInstallerExeFile = Path.Combine(installationPath, @"Common7\IDE\VSIXInstaller.exe");
 
            var temporaryFolder = Path.Combine(Path.GetTempPath(), "vs-extension-testing", Path.GetRandomFileName());
            Assert.False(Directory.Exists(temporaryFolder));
            Directory.CreateDirectory(temporaryFolder);
 
            var integrationTestServiceExtension = ExtractIntegrationTestServiceExtension(temporaryFolder);
            var extensions = extensionFiles.Add(integrationTestServiceExtension);
 
            var logDir = DataCollectionService.GetLogDirectory();
            if (!Directory.Exists(logDir))
            {
                Directory.CreateDirectory(logDir);
            }
 
            var logFileName = $"VSExtensionTestingInstallerLog-{Guid.NewGuid():N}.log";
 
            try
            {
                var baseArguments = ImmutableArray.Create("/quiet", "/shutdownprocesses", $"/instanceIds:{vsInstanceId}", $"/logFile:{logFileName}");
                if (!string.IsNullOrEmpty(rootSuffix))
                {
                    baseArguments = baseArguments.Add($"/rootSuffix:{rootSuffix}");
                }
 
                foreach (var extension in extensions)
                {
                    var arguments = string.Join(" ", baseArguments.Add($"\"{extension}\""));
                    Debug.WriteLine($"{vsixInstallerExeFile} {arguments}");
 
                    var installProcessStartInfo = CreateStartInfo(vsixInstallerExeFile, silent: true, arguments);
                    installProcessStartInfo.RedirectStandardError = true;
                    installProcessStartInfo.RedirectStandardOutput = true;
                    using var installProcess = Process.Start(installProcessStartInfo);
                    var standardErrorAsync = installProcess.StandardError.ReadToEndAsync();
                    var standardOutputAsync = installProcess.StandardOutput.ReadToEndAsync();
                    installProcess.WaitForExit();
 
                    if (installProcess.ExitCode != 0)
                    {
                        var messageBuilder = new StringBuilder();
                        messageBuilder.AppendLine($"VSIX installer failed with exit code: {installProcess.ExitCode}");
                        messageBuilder.AppendLine();
                        messageBuilder.AppendLine($"Standard Error:");
                        messageBuilder.AppendLine(await standardErrorAsync.ConfigureAwait(true));
                        messageBuilder.AppendLine();
                        messageBuilder.AppendLine($"Standard Output:");
                        messageBuilder.AppendLine(await standardOutputAsync.ConfigureAwait(true));
 
                        throw new InvalidOperationException(messageBuilder.ToString());
                    }
                }
            }
            finally
            {
                File.Delete(integrationTestServiceExtension);
                Directory.Delete(temporaryFolder, recursive: true);
 
                // Copy installer log file to log directory
                var installerLog = Path.Combine(Path.GetTempPath(), logFileName);
                if (File.Exists(installerLog))
                {
                    var logDestination = Path.Combine(logDir, logFileName);
                    File.Move(installerLog, logDestination);
                    Debug.WriteLine($"Moved '{installerLog}' to '{logDestination}'.");
                }
                else
                {
                    Debug.WriteLine($"The installer log file '{installerLog}' was not found.");
                }
            }
 
            if (version.Major >= 16)
            {
                // Make sure the start window doesn't show on launch
                Process.Start(CreateStartInfo(vsRegEditExeFile, silent: true, $"set \"{installationPath}\" \"{rootSuffix}\" HKCU General OnEnvironmentStartup dword 10")).WaitForExit();
            }
 
            var vsLaunchArgs = string.Empty;
            if (!string.IsNullOrWhiteSpace(rootSuffix))
            {
                vsLaunchArgs += $"/rootsuffix \"{rootSuffix}\"";
            }
 
            // BUG: Currently building with /p:DeployExtension=true does not always cause the MEF cache to recompose...
            //      So, run clearcache and updateconfiguration to workaround https://devdiv.visualstudio.com/DevDiv/_workitems?id=385351.
            if (version.Major >= 12)
            {
                var clearCacheProcess = Process.Start(CreateStartInfo(vsExeFile, silent: true, $"/clearcache {vsLaunchArgs}"));
                TakeSnapshotEveryTimeSpanUntilProcessExit(clearCacheProcess, "clearcache");
            }
 
            var updateConfigProcess = Process.Start(CreateStartInfo(vsExeFile, silent: true, $"/updateconfiguration {vsLaunchArgs}"));
            TakeSnapshotEveryTimeSpanUntilProcessExit(updateConfigProcess, "updateconfiguration");
 
            var resetSettingsProcess = Process.Start(CreateStartInfo(vsExeFile, silent: true, $"/resetsettings General.vssettings /command \"File.Exit\" {vsLaunchArgs}"));
            TakeSnapshotEveryTimeSpanUntilProcessExit(resetSettingsProcess, "resetsettings");
 
            // Make sure we kill any leftover processes spawned by the host
            IntegrationHelper.KillProcess("DbgCLR");
            IntegrationHelper.KillProcess("VsJITDebugger");
            IntegrationHelper.KillProcess("dexplore");
 
            var process = Process.Start(CreateStartInfo(vsExeFile, silent: false, vsLaunchArgs));
 
            // Run the snapshot collection operation, but don't block on its completion for the actual test execution
            _ = Task.Run(() => TakeSnapshotEveryTimeSpanUntilProcessExit(process, $"devenv{process.Id}"));
 
            Debug.WriteLine($"Launched a new instance of Visual Studio. (ID: {process.Id})");
 
            return process;
 
            ProcessStartInfo CreateStartInfo(string fileName, bool silent, string arguments)
            {
                var startInfo = new ProcessStartInfo(fileName, arguments) { CreateNoWindow = silent, UseShellExecute = false };
                foreach (var variable in environmentVariables)
                {
                    if (string.IsNullOrEmpty(variable.Value))
                    {
                        startInfo.EnvironmentVariables.Remove(variable.Key);
                    }
                    else
                    {
                        startInfo.EnvironmentVariables[variable.Key] = variable.Value;
                    }
                }
 
                return startInfo;
            }
        }
 
        private static string ExtractIntegrationTestServiceExtension(string temporaryFolder)
        {
            var extensionFileName = "Microsoft.VisualStudio.IntegrationTestService.vsix";
            var path = Path.Combine(temporaryFolder, extensionFileName);
            using (var resourceStream = typeof(VisualStudioInstanceFactory).Assembly.GetManifestResourceStream(extensionFileName))
            using (var writerStream = File.Open(path, FileMode.CreateNew, FileAccess.Write))
            {
                resourceStream.CopyTo(writerStream);
            }
 
            return path;
        }
 
        private static void TakeSnapshotEveryTimeSpanUntilProcessExit(Process process, string commandBeingExecuted)
        {
            var dir = DataCollectionService.GetLogDirectory();
            if (!Directory.Exists(dir))
            {
                Directory.CreateDirectory(dir);
            }
 
            using var cancellatokenSource = new CancellationTokenSource();
 
            try
            {
                _ = Task.Run(() => TakeScreenShotEveryTimeIntervalAsync(dir, commandBeingExecuted, cancellatokenSource.Token));
                process.WaitForExit();
            }
            finally
            {
                cancellatokenSource.Cancel();
            }
 
            static async Task TakeScreenShotEveryTimeIntervalAsync(string directory, string commandBeingExecuted, CancellationToken cancellationToken)
            {
                var count = 1;
                while (!cancellationToken.IsCancellationRequested)
                {
                    await Task.Delay(ReportTimeInterval, cancellationToken).ConfigureAwait(false);
                    ScreenshotService.TakeScreenshot(Path.Combine(Path.GetFullPath(directory), commandBeingExecuted, $"_after_{count * ReportTimeMinute}_min.png"));
                    count++;
                }
            }
        }
 
        public void Dispose()
        {
            if (!_leaveRunning)
            {
                _currentlyRunningInstance?.Close();
            }
 
            _currentlyRunningInstance = null;
 
            // We want to make sure everybody cleaned up their contexts by the end of everything
            ThrowExceptionIfAlreadyHasActiveContext();
 
            AppDomain.CurrentDomain.AssemblyResolve -= AssemblyResolveHandler;
        }
 
        // The life of this object is managed explicitly
        public override object? InitializeLifetimeService()
        {
            return null;
        }
    }
}