Automatically add plugin projects to Windows .sln (#51246)

Adds utility code for managing list of plugin projects within a solution file, updating them as the plugins change.

This is a prototype of an approach to solution-level portion of Windows plugin tooling; it may not be what the final plugin handling on Windows uses, but it makes things much better in the short term, and gives us a baseline to evaluate other possible solution management systems against.

Part of #32719
diff --git a/packages/flutter_tools/test/general.shard/plugins_test.dart b/packages/flutter_tools/test/general.shard/plugins_test.dart
index 63f25b7..7fb5e87 100644
--- a/packages/flutter_tools/test/general.shard/plugins_test.dart
+++ b/packages/flutter_tools/test/general.shard/plugins_test.dart
@@ -76,6 +76,7 @@
       final Directory windowsManagedDirectory = flutterProject.directory.childDirectory('windows').childDirectory('flutter');
       when(windowsProject.managedDirectory).thenReturn(windowsManagedDirectory);
       when(windowsProject.vcprojFile).thenReturn(windowsManagedDirectory.parent.childFile('Runner.vcxproj'));
+      when(windowsProject.solutionFile).thenReturn(windowsManagedDirectory.parent.childFile('Runner.sln'));
       when(windowsProject.pluginSymlinkDirectory).thenReturn(windowsManagedDirectory.childDirectory('ephemeral').childDirectory('.plugin_symlinks'));
       when(windowsProject.generatedPluginPropertySheetFile).thenReturn(windowsManagedDirectory.childFile('GeneratedPlugins.props'));
       when(windowsProject.existsSync()).thenReturn(false);
@@ -309,6 +310,44 @@
       project.podManifestLock.createSync(recursive: true);
     }
 
+    // Creates a Windows solution file sufficient to allow plugin injection
+    // to run without failing.
+    void createDummyWindowsSolutionFile() {
+      windowsProject.solutionFile.createSync(recursive: true);
+      // This isn't a valid solution file, but it's just enough to work with the
+      // plugin injection.
+      windowsProject.solutionFile.writeAsStringSync('''
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Runner", "Runner.vcxproj", "{3842E94C-E348-463A-ADBE-625A2B69B628}"
+	ProjectSection(ProjectDependencies) = postProject
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+	EndProjectSection
+EndProject
+Global
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+	EndGlobalSection
+EndGlobal''');
+    }
+
+    // Creates a Windows project file for dummyPackageDirectory sufficient to
+    // allow plugin injection to run without failing.
+    void createDummyPluginWindowsProjectFile() {
+      final File projectFile = dummyPackageDirectory
+        .parent
+        .childDirectory('windows')
+        .childFile('plugin.vcxproj');
+      projectFile.createSync(recursive: true);
+      // This isn't a valid project file, but it's just enough to work with the
+      // plugin injection.
+      projectFile.writeAsStringSync('''
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <PropertyGroup Label="Globals">
+    <ProjectGuid>{5919689F-A5D5-462C-AF50-D405CCEF89B8}</ProjectGuid>'}
+    <ProjectName>apackage</ProjectName>
+  </PropertyGroup>
+</Project>''');
+    }
+
     group('refreshPlugins', () {
       testUsingContext('Refreshing the plugin list is a no-op when the plugins list stays empty', () {
         refreshPluginsList(flutterProject);
@@ -837,6 +876,8 @@
         when(featureFlags.isWindowsEnabled).thenReturn(true);
         when(flutterProject.isModule).thenReturn(false);
         configureDummyPackageAsPlugin();
+        createDummyWindowsSolutionFile();
+        createDummyPluginWindowsProjectFile();
 
         await injectPlugins(flutterProject, checkProjects: true);
 
@@ -857,6 +898,8 @@
         when(featureFlags.isWindowsEnabled).thenReturn(true);
         when(flutterProject.isModule).thenReturn(false);
         configureDummyPackageAsPlugin();
+        createDummyWindowsSolutionFile();
+        createDummyPluginWindowsProjectFile();
 
         await injectPlugins(flutterProject, checkProjects: true);
 
@@ -871,6 +914,23 @@
         ProcessManager: () => FakeProcessManager.any(),
         FeatureFlags: () => featureFlags,
       });
+
+      testUsingContext('Injecting updates Windows solution file', () async {
+        when(windowsProject.existsSync()).thenReturn(true);
+        when(featureFlags.isWindowsEnabled).thenReturn(true);
+        when(flutterProject.isModule).thenReturn(false);
+        configureDummyPackageAsPlugin();
+        createDummyWindowsSolutionFile();
+        createDummyPluginWindowsProjectFile();
+
+        await injectPlugins(flutterProject, checkProjects: true);
+
+        expect(windowsProject.solutionFile.readAsStringSync(), contains(r'apackage\windows\plugin.vcxproj'));
+      }, overrides: <Type, Generator>{
+        FileSystem: () => fs,
+        ProcessManager: () => FakeProcessManager.any(),
+        FeatureFlags: () => featureFlags,
+      });
     });
 
     group('createPluginSymlinks', () {
diff --git a/packages/flutter_tools/test/general.shard/project_test.dart b/packages/flutter_tools/test/general.shard/project_test.dart
index 49d2c41..2d8bfab 100644
--- a/packages/flutter_tools/test/general.shard/project_test.dart
+++ b/packages/flutter_tools/test/general.shard/project_test.dart
@@ -225,6 +225,20 @@
       testUsingContext('injects plugins for Windows', () async {
         final FlutterProject project = await someProject();
         project.windows.managedDirectory.createSync(recursive: true);
+        project.windows.solutionFile.createSync(recursive: true);
+        // Just enough solution file to allow injection to pass.
+        // TODO(stuartmorgan): Consider allowing injecting a mock solution util
+        // class into the test environment instead.
+        project.windows.solutionFile.writeAsStringSync('''
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Runner", "Runner.vcxproj", "{3842E94C-E348-463A-ADBE-625A2B69B628}"
+	ProjectSection(ProjectDependencies) = postProject
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+	EndProjectSection
+EndProject
+Global
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+	EndGlobalSection
+EndGlobal''');
         await project.ensureReadyForPlatformSpecificTooling();
         expectExists(project.windows.managedDirectory.childFile('generated_plugin_registrant.h'));
         expectExists(project.windows.managedDirectory.childFile('generated_plugin_registrant.cc'));
diff --git a/packages/flutter_tools/test/general.shard/windows/visual_studio_project_test.dart b/packages/flutter_tools/test/general.shard/windows/visual_studio_project_test.dart
new file mode 100644
index 0000000..47abaeb
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/windows/visual_studio_project_test.dart
@@ -0,0 +1,105 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:file/memory.dart';
+import 'package:file_testing/file_testing.dart';
+import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/windows/visual_studio_project.dart';
+
+import '../../src/common.dart';
+
+void main() {
+  group('Visual Studio Project', () {
+    String generateProjectContents({String guid, String name}) {
+      // A bare-bones project.
+      return '''
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <ItemGroup Label="ProjectConfigurations">
+    <ProjectConfiguration Include="Debug|x64">
+      <Configuration>Debug</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+    <ProjectConfiguration Include="Release|x64">
+      <Configuration>Release</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+  </ItemGroup>
+  <PropertyGroup Label="Globals">
+    <VCProjectVersion>15.0</VCProjectVersion>
+    ${guid == null ? '' : '<ProjectGuid>{$guid}</ProjectGuid>'}
+    ${name == null ? '' : '<ProjectName>$name</ProjectName>'}
+    <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
+  </PropertyGroup>
+  <Import Project="\$(VCTargetsPath)\\Microsoft.Cpp.Default.props" />
+  <PropertyGroup Label="Configuration">
+    <PlatformToolset>v142</PlatformToolset>
+  </PropertyGroup>
+  <Import Project="\$(VCTargetsPath)\\Microsoft.Cpp.props" />
+  <ImportGroup Label="ExtensionSettings">
+  </ImportGroup>
+  <ImportGroup Label="Shared">
+  </ImportGroup>
+  <ImportGroup Label="PropertySheets" />
+  <PropertyGroup Label="UserMacros" />
+  <PropertyGroup />
+  <ItemDefinitionGroup />
+  <ItemGroup>
+  </ItemGroup>
+  <Import Project="\$(VCTargetsPath)\\Microsoft.Cpp.targets" />
+  <ImportGroup Label="ExtensionTargets">
+  </ImportGroup>
+</Project>''';
+    }
+
+    test('Property extraction works on a simple vcxproj', () async {
+      final FileSystem fileSystem = MemoryFileSystem();
+      const String guid = '017C4BAC-FEBA-406D-8A2C-3099FFE9D811';
+      const String name = 'Test';
+      final File projectFile = fileSystem.file('aproject.vcxproj');
+      projectFile.writeAsStringSync(generateProjectContents(guid: guid, name: name));
+
+      final VisualStudioProject project = VisualStudioProject(projectFile, fileSystem: fileSystem);
+      expect(project.formatUnderstood, true);
+      expect(project.guid, guid);
+      expect(project.name, name);
+    });
+
+    test('Missing GUID returns null', () async {
+      final FileSystem fileSystem = MemoryFileSystem();
+      final File projectFile = fileSystem.file('aproject.vcxproj');
+      projectFile.writeAsStringSync(generateProjectContents());
+
+      final VisualStudioProject project = VisualStudioProject(projectFile, fileSystem: fileSystem);
+      expect(project.formatUnderstood, true);
+      expect(project.guid, null);
+    });
+
+    test('Missing project name uses filename', () async {
+      final FileSystem fileSystem = MemoryFileSystem();
+      final File projectFile = fileSystem.file('aproject.vcxproj');
+      projectFile.writeAsStringSync(generateProjectContents());
+
+      final VisualStudioProject project = VisualStudioProject(projectFile, fileSystem: fileSystem);
+      expect(project.formatUnderstood, true);
+      expect(project.name, 'aproject');
+    });
+
+    test('Unknown file contents creates an object, and return false for formatUnderstood', () async {
+      final FileSystem fileSystem = MemoryFileSystem();
+      final File projectFile = fileSystem.file('aproject.vcxproj');
+      projectFile.writeAsStringSync('This is not XML!');
+
+      final VisualStudioProject project = VisualStudioProject(projectFile, fileSystem: fileSystem);
+      expect(project.formatUnderstood, false);
+    });
+
+    test('Missing project file throws on creation', () async {
+      final FileSystem fileSystem = MemoryFileSystem();
+      final File projectFile = fileSystem.file('aproject.vcxproj');
+
+      expect(() => VisualStudioProject(projectFile, fileSystem: fileSystem), throwsFileSystemException());
+    });
+  });
+}
diff --git a/packages/flutter_tools/test/general.shard/windows/visual_studio_solution_utils_test.dart b/packages/flutter_tools/test/general.shard/windows/visual_studio_solution_utils_test.dart
new file mode 100644
index 0000000..ee7b8c0
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/windows/visual_studio_solution_utils_test.dart
@@ -0,0 +1,429 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:file/memory.dart';
+import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/convert.dart';
+import 'package:flutter_tools/src/plugins.dart';
+import 'package:flutter_tools/src/platform_plugins.dart';
+import 'package:flutter_tools/src/project.dart';
+import 'package:flutter_tools/src/windows/visual_studio_solution_utils.dart';
+import 'package:mockito/mockito.dart';
+
+import '../../src/common.dart';
+
+void main() {
+  group('Visual Studio Solution Utils', () {
+    // Magic values; see visual_studio_solution_utils.dart.
+    const String solutionTypeGuidFolder = '2150E333-8FDC-42A3-9474-1A3956D46DE8';
+    const String solutionTypeGuidVcxproj = '8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942';
+    const String flutterPluginSolutionFolderGuid = '5C2E738A-1DD3-445A-AAC8-EEB9648DD07C';
+
+    // Arbitrary random GUIDs to use for fake plugins.
+    const String pluginAGuid = '9F200BC4-A747-43D1-8B72-B778F2C4D048';
+    const String pluginBGuid = '39AC79B8-28A6-4526-B5FF-9C83F59B3AF0';
+    const String pluginCGuid = '8E010714-28FF-416A-BC6F-9CDE336A02A7';
+    const String pluginDGuid = '304F1860-7E8B-4C99-8E1D-F5E55259F5C3';
+
+    FileSystem fs;
+    MockWindowsProject project;
+
+    setUp(() async {
+      fs = MemoryFileSystem(style: FileSystemStyle.windows);
+
+      project = MockWindowsProject();
+      when(project.pluginConfigKey).thenReturn('windows');
+      final Directory windowsManagedDirectory = fs.directory('C:').childDirectory('windows').childDirectory('flutter');
+      when(project.solutionFile).thenReturn(windowsManagedDirectory.parent.childFile('Runner.sln'));
+      when(project.vcprojFile).thenReturn(windowsManagedDirectory.parent.childFile('Runner.vcxproj'));
+      when(project.pluginSymlinkDirectory).thenReturn(windowsManagedDirectory.childDirectory('ephemeral').childDirectory('.plugin_symlinks'));
+    });
+
+    // Returns a solution file contents for a solution without any plugins.
+    void writeSolutionWithoutPlugins() {
+      project.solutionFile.createSync(recursive: true);
+      project.solutionFile.writeAsStringSync('''
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.29709.97
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Runner", "Runner.vcxproj", "{3842E94C-E348-463A-ADBE-625A2B69B628}"
+	ProjectSection(ProjectDependencies) = postProject
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+	EndProjectSection
+EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Flutter Build", "FlutterBuild.vcxproj", "{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|x64 = Debug|x64
+		Profile|x64 = Profile|x64
+		Release|x64 = Release|x64
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{3842E94C-E348-463A-ADBE-625A2B69B628}.Debug|x64.ActiveCfg = Debug|x64
+		{3842E94C-E348-463A-ADBE-625A2B69B628}.Debug|x64.Build.0 = Debug|x64
+		{3842E94C-E348-463A-ADBE-625A2B69B628}.Profile|x64.ActiveCfg = Profile|x64
+		{3842E94C-E348-463A-ADBE-625A2B69B628}.Profile|x64.Build.0 = Profile|x64
+		{3842E94C-E348-463A-ADBE-625A2B69B628}.Release|x64.ActiveCfg = Release|x64
+		{3842E94C-E348-463A-ADBE-625A2B69B628}.Release|x64.Build.0 = Release|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.ActiveCfg = Debug|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.Build.0 = Debug|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Profile|x64.ActiveCfg = Profile|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Profile|x64.Build.0 = Profile|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Release|x64.ActiveCfg = Release|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Release|x64.Build.0 = Release|x64
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {B8A69CB0-A974-4774-9EBD-1E5EECACD186}
+	EndGlobalSection
+EndGlobal''');
+    }
+
+    // Returns a solution file contents for a solution with plugins A, B, and C
+    // already added.
+    void writeSolutionWithPlugins() {
+      const String pluginSymlinkSubdirPath = r'Flutter\ephemeral\.plugin_symlinks';
+      const String pluginProjectSubpath = r'windows\plugin.vcxproj';
+      project.solutionFile.createSync(recursive: true);
+      project.solutionFile.writeAsStringSync('''
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.29709.97
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Runner", "Runner.vcxproj", "{5A827760-CF8B-408A-99A3-B6C0AD2271E7}"
+	ProjectSection(ProjectDependencies) = postProject
+		{$pluginAGuid} = {$pluginAGuid}
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+		{$pluginBGuid} = {$pluginBGuid}
+		{$pluginCGuid} = {$pluginCGuid}
+	EndProjectSection
+EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "plugin_a", "$pluginSymlinkSubdirPath\\plugin_a\\$pluginProjectSubpath", "{$pluginAGuid}"
+	ProjectSection(ProjectDependencies) = postProject
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+	EndProjectSection
+EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Flutter Build", "FlutterBuild.vcxproj", "{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}"
+EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "plugin_b", "$pluginSymlinkSubdirPath\\plugin_b\\$pluginProjectSubpath", "{$pluginBGuid}"
+	ProjectSection(ProjectDependencies) = postProject
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+	EndProjectSection
+EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "plugin_c", "$pluginSymlinkSubdirPath\\plugin_c\\$pluginProjectSubpath", "{$pluginCGuid}"
+	ProjectSection(ProjectDependencies) = postProject
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}
+	EndProjectSection
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Flutter Plugins", "Flutter Plugins", "{5C2E738A-1DD3-445A-AAC8-EEB9648DD07C}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|x64 = Debug|x64
+		Profile|x64 = Profile|x64
+		Release|x64 = Release|x64
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Debug|x64.ActiveCfg = Debug|x64
+		{5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Debug|x64.Build.0 = Debug|x64
+		{5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Profile|x64.ActiveCfg = Profile|x64
+		{5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Profile|x64.Build.0 = Profile|x64
+		{5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Release|x64.ActiveCfg = Release|x64
+		{5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Release|x64.Build.0 = Release|x64
+		{$pluginAGuid}.Debug|x64.ActiveCfg = Debug|x64
+		{$pluginAGuid}.Debug|x64.Build.0 = Debug|x64
+		{$pluginAGuid}.Profile|x64.ActiveCfg = Profile|x64
+		{$pluginAGuid}.Profile|x64.Build.0 = Profile|x64
+		{$pluginAGuid}.Release|x64.ActiveCfg = Release|x64
+		{$pluginAGuid}.Release|x64.Build.0 = Release|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.ActiveCfg = Debug|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.Build.0 = Debug|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Profile|x64.ActiveCfg = Profile|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Profile|x64.Build.0 = Profile|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Release|x64.ActiveCfg = Release|x64
+		{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Release|x64.Build.0 = Release|x64
+		{$pluginBGuid}.Debug|x64.ActiveCfg = Debug|x64
+		{$pluginBGuid}.Debug|x64.Build.0 = Debug|x64
+		{$pluginBGuid}.Profile|x64.ActiveCfg = Profile|x64
+		{$pluginBGuid}.Profile|x64.Build.0 = Profile|x64
+		{$pluginBGuid}.Release|x64.ActiveCfg = Release|x64
+		{$pluginBGuid}.Release|x64.Build.0 = Release|x64
+		{$pluginCGuid}.Debug|x64.ActiveCfg = Debug|x64
+		{$pluginCGuid}.Debug|x64.Build.0 = Debug|x64
+		{$pluginCGuid}.Profile|x64.ActiveCfg = Profile|x64
+		{$pluginCGuid}.Profile|x64.Build.0 = Profile|x64
+		{$pluginCGuid}.Release|x64.ActiveCfg = Release|x64
+		{$pluginCGuid}.Release|x64.Build.0 = Release|x64
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(NestedProjects) = preSolution
+		{$pluginCGuid} = {$flutterPluginSolutionFolderGuid}
+		{$pluginBGuid} = {$flutterPluginSolutionFolderGuid}
+		{$pluginAGuid} = {$flutterPluginSolutionFolderGuid}
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {6C8A8041-10D8-4BEB-B73D-C02BCE62120B}
+	EndGlobalSection
+EndGlobal''');
+    }
+
+    void writeDummyPluginProject(String pluginName, String guid) {
+      final File pluginProjectFile = project.pluginSymlinkDirectory
+          .childDirectory(pluginName)
+          .childDirectory('windows')
+          .childFile('plugin.vcxproj');
+      pluginProjectFile.createSync(recursive: true);
+      pluginProjectFile.writeAsStringSync('''
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <ItemGroup Label="ProjectConfigurations">
+    <ProjectConfiguration Include="Debug|x64">
+      <Configuration>Debug</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+    <ProjectConfiguration Include="Release|x64">
+      <Configuration>Release</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+  </ItemGroup>
+  <PropertyGroup Label="Globals">
+    <VCProjectVersion>15.0</VCProjectVersion>
+    <ProjectGuid>{$guid}</ProjectGuid>
+    <ProjectName>$pluginName</ProjectName>
+    <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
+  </PropertyGroup>
+  <Import Project="\$(VCTargetsPath)\\Microsoft.Cpp.Default.props" />
+  <PropertyGroup Label="Configuration">
+    <PlatformToolset>v142</PlatformToolset>
+  </PropertyGroup>
+  <Import Project="\$(VCTargetsPath)\\Microsoft.Cpp.props" />
+  <ImportGroup Label="ExtensionSettings">
+  </ImportGroup>
+  <ImportGroup Label="Shared">
+  </ImportGroup>
+  <ImportGroup Label="PropertySheets" />
+  <PropertyGroup Label="UserMacros" />
+  <PropertyGroup />
+  <ItemDefinitionGroup />
+  <ItemGroup>
+  </ItemGroup>
+  <Import Project="\$(VCTargetsPath)\\Microsoft.Cpp.targets" />
+  <ImportGroup Label="ExtensionTargets">
+  </ImportGroup>
+</Project>''');
+    }
+
+    // Configures and returns a mock plugin with the given name and GUID in the
+    // project's plugin symlink directory.
+    Plugin getMockPlugin(String name, String guid) {
+      final MockPlugin plugin = MockPlugin();
+      when(plugin.platforms).thenReturn(<String, PluginPlatform>{project.pluginConfigKey: null});
+      when(plugin.name).thenReturn(name);
+      when(plugin.path).thenReturn(project.pluginSymlinkDirectory.childDirectory(name).path);
+      writeDummyPluginProject(name, guid);
+      return plugin;
+    }
+
+    test('Adding the first plugin to a solution adds the expected references', () async {
+      writeSolutionWithoutPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+        getMockPlugin('plugin_a', pluginAGuid),
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+
+      // Check for:
+      // - Plugin project.
+      final String pluginSubpath = fs.path.join('Flutter', 'ephemeral', '.plugin_symlinks', 'plugin_a', 'windows', 'plugin.vcxproj');
+      expect(newSolutionContents, contains('Project("{$solutionTypeGuidVcxproj}") = "plugin_a", "$pluginSubpath", "{$pluginAGuid}"'));
+      // - A dependency on the plugin project (from the Runner).
+      expect(newSolutionContents, contains('{$pluginAGuid} = {$pluginAGuid}'));
+      // - Plugin project configurations.
+      expect(newSolutionContents, contains('{$pluginAGuid}.Debug|x64.ActiveCfg = Debug|x64'));
+      expect(newSolutionContents, contains('{$pluginAGuid}.Debug|x64.Build.0 = Debug|x64'));
+      expect(newSolutionContents, contains('{$pluginAGuid}.Profile|x64.ActiveCfg = Profile|x64'));
+      expect(newSolutionContents, contains('{$pluginAGuid}.Profile|x64.Build.0 = Profile|x64'));
+      expect(newSolutionContents, contains('{$pluginAGuid}.Release|x64.ActiveCfg = Release|x64'));
+      expect(newSolutionContents, contains('{$pluginAGuid}.Release|x64.Build.0 = Release|x64'));
+      // - A plugin folder, and a child reference for the plugin.
+      expect(newSolutionContents, contains('Project("{$solutionTypeGuidFolder}") = "Flutter Plugins", "Flutter Plugins", "{$flutterPluginSolutionFolderGuid}"'));
+      expect(newSolutionContents, contains('{$pluginAGuid} = {$flutterPluginSolutionFolderGuid}'));
+    });
+
+    test('Removing a plugin removes entries as expected', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+        getMockPlugin('plugin_a', pluginAGuid),
+        getMockPlugin('plugin_c', pluginCGuid),
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+
+      // There should be no references to plugin B anywhere.
+      expect(newSolutionContents.contains(pluginBGuid), false);
+
+      // All the plugin A & C references should still be present in modified
+      // sections.
+      for (final String guid in <String>[pluginAGuid, pluginCGuid]) {
+        expect(newSolutionContents, contains('{$guid} = {$guid}'));
+        expect(newSolutionContents, contains('{$guid}.Debug|x64.ActiveCfg = Debug|x64'));
+        expect(newSolutionContents, contains('{$guid} = {$flutterPluginSolutionFolderGuid}'));
+      }
+    });
+
+    test('Removing all plugins works', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins( plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+
+      // There should be no references to any of the plugins.
+      expect(newSolutionContents.contains(pluginAGuid), false);
+      expect(newSolutionContents.contains(pluginBGuid), false);
+      expect(newSolutionContents.contains(pluginCGuid), false);
+      // Nor any plugins in the Flutter Plugins folder.
+      expect(newSolutionContents.contains('= {$flutterPluginSolutionFolderGuid}'), false);
+    });
+
+    test('Adjusting the plugin list by adding and removing adjusts entries as expected', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+        getMockPlugin('plugin_b', pluginBGuid),
+        getMockPlugin('plugin_c', pluginCGuid),
+        getMockPlugin('plugin_d', pluginDGuid),
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+
+      // There should be no references to plugin A anywhere.
+      expect(newSolutionContents.contains(pluginAGuid), false);
+
+      // All the plugin B & C references should still be present in modified
+      // sections.
+      for (final String guid in <String>[pluginBGuid, pluginCGuid]) {
+        expect(newSolutionContents, contains('{$guid} = {$guid}'));
+        expect(newSolutionContents, contains('{$guid}.Debug|x64.ActiveCfg = Debug|x64'));
+        expect(newSolutionContents, contains('{$guid} = {$flutterPluginSolutionFolderGuid}'));
+      }
+
+      // All the plugin D values should be added:
+      // - Plugin project.
+      final String pluginSubpath = fs.path.join('Flutter', 'ephemeral', '.plugin_symlinks', 'plugin_d', 'windows', 'plugin.vcxproj');
+      expect(newSolutionContents, contains('Project("{$solutionTypeGuidVcxproj}") = "plugin_d", "$pluginSubpath", "{$pluginDGuid}"'));
+      // - A dependency on the plugin project (from the Runner).
+      expect(newSolutionContents, contains('{$pluginDGuid} = {$pluginDGuid}'));
+      // - Plugin project configurations.
+      expect(newSolutionContents, contains('{$pluginDGuid}.Debug|x64.ActiveCfg = Debug|x64'));
+      expect(newSolutionContents, contains('{$pluginDGuid}.Debug|x64.Build.0 = Debug|x64'));
+      expect(newSolutionContents, contains('{$pluginDGuid}.Profile|x64.ActiveCfg = Profile|x64'));
+      expect(newSolutionContents, contains('{$pluginDGuid}.Profile|x64.Build.0 = Profile|x64'));
+      expect(newSolutionContents, contains('{$pluginDGuid}.Release|x64.ActiveCfg = Release|x64'));
+      expect(newSolutionContents, contains('{$pluginDGuid}.Release|x64.Build.0 = Release|x64'));
+      // - A child reference for the plugin in the Flutter Plugins folder.
+      expect(newSolutionContents, contains('{$pluginDGuid} = {$flutterPluginSolutionFolderGuid}'));
+    });
+
+    test('Adding plugins doesn\'t create duplicate entries', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+        getMockPlugin('plugin_a', pluginAGuid),
+        getMockPlugin('plugin_b', pluginBGuid),
+        getMockPlugin('plugin_c', pluginCGuid),
+        getMockPlugin('plugin_d', pluginDGuid),
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+      // There should only be:
+      // - one Flutter Plugins folder.
+      expect('Project("{$solutionTypeGuidFolder}")'.allMatches(newSolutionContents).length, 1);
+      // - one copy of plugin A's project.
+      expect('Project("{$solutionTypeGuidVcxproj}") = "plugin_a"'.allMatches(newSolutionContents).length, 1);
+      // - one copy of plugin A's configuration entries.
+      expect('{$pluginAGuid}.Debug|x64.ActiveCfg = Debug|x64'.allMatches(newSolutionContents).length, 1);
+      // - one dependency from the Runner to plugin A.
+      expect('{$pluginAGuid} = {$pluginAGuid}'.allMatches(newSolutionContents).length, 1);
+      // - one copy of plugin A in Flutter Plugins.
+      expect('{$pluginAGuid} = {$flutterPluginSolutionFolderGuid}'.allMatches(newSolutionContents).length, 1);
+    });
+
+    test('Adding plugins doesn\'t change ordering', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+        getMockPlugin('plugin_a', pluginAGuid),
+        getMockPlugin('plugin_b', pluginBGuid),
+        getMockPlugin('plugin_c', pluginCGuid),
+        getMockPlugin('plugin_d', pluginDGuid),
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+      // Plugin A should still be before Flutter Build in the Runner dependencies.
+      expect(newSolutionContents.indexOf('{$pluginAGuid} = {$pluginAGuid}'),
+          lessThan(newSolutionContents.indexOf('{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}')));
+      // ... and in the build configuration list.
+      expect(newSolutionContents.indexOf('{$pluginAGuid}.Debug|x64.ActiveCfg = Debug|x64'),
+          lessThan(newSolutionContents.indexOf('{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.ActiveCfg = Debug|x64')));
+      // And plugin C should still be before plugin A in the Flutter Plugins nesting list.
+      expect(newSolutionContents.indexOf('{$pluginCGuid} = {$flutterPluginSolutionFolderGuid}'),
+          lessThan(newSolutionContents.indexOf('{$pluginAGuid} = {$flutterPluginSolutionFolderGuid}')));
+    });
+
+    test('Updating solution preserves BOM', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      // Visual Studio expects sln files to start with a BOM.
+      final List<int> solutionStartingBytes = project.solutionFile.readAsBytesSync().take(3).toList();
+      final List<int> bomBytes = utf8.encode(String.fromCharCode(unicodeBomCharacterRune));
+      expect(solutionStartingBytes, bomBytes);
+    });
+
+    test('Updating solution dosen\'t introduce unexpected whitespace', () async {
+      writeSolutionWithPlugins();
+
+      final List<Plugin> plugins = <Plugin>[
+        getMockPlugin('plugin_a', pluginAGuid),
+        getMockPlugin('plugin_b', pluginBGuid),
+      ];
+      await VisualStudioSolutionUtils(project: project, fileSystem: fs).updatePlugins(plugins);
+
+      final String newSolutionContents = project.solutionFile.readAsStringSync();
+      // Project, EndProject, Global, and EndGlobal should be at the start of
+      // lines.
+      expect(RegExp(r'^[ \t]+Project\(', multiLine: true).hasMatch(newSolutionContents), false);
+      expect(RegExp(r'^[ \t]+EndProject\s*$', multiLine: true).hasMatch(newSolutionContents), false);
+      expect(RegExp(r'^[ \t]+Global\s*$', multiLine: true).hasMatch(newSolutionContents), false);
+      expect(RegExp(r'^[ \t]+EndGlobal\s*$', multiLine: true).hasMatch(newSolutionContents), false);
+      // ProjectSection, GlobalSection, and their ends should be indented
+      // exactly one tab.
+      expect(RegExp(r'^([ \t]+\t|\t[ \t]+)ProjectSection\(', multiLine: true).hasMatch(newSolutionContents), false);
+      expect(RegExp(r'^([ \t]+\t|\t[ \t]+)EndProjectSection\s*$', multiLine: true).hasMatch(newSolutionContents), false);
+      expect(RegExp(r'^([ \t]+\t|\t[ \t]+)GlobalSection\(\s*$', multiLine: true).hasMatch(newSolutionContents), false);
+      expect(RegExp(r'^([ \t]+\t|\t[ \t]+)EndGlobalSection\s*$', multiLine: true).hasMatch(newSolutionContents), false);
+    });
+  });
+}
+
+class MockWindowsProject extends Mock implements WindowsProject {}
+class MockPlugin extends Mock implements Plugin {}