diff --git a/.vsts-ci.yml b/.vsts-ci.yml index 7a69824b298c..a45d0c784f08 100644 --- a/.vsts-ci.yml +++ b/.vsts-ci.yml @@ -366,16 +366,6 @@ extends: runtimeIdentifier: osx-arm64 osProperties: /p:CrossBuild=true - ############### DOTNET-FORMAT ############### - - ${{ if or(eq(parameters.runTestBuild, true), eq(variables['Build.Reason'], 'PullRequest')) }}: - - template: /eng/dotnet-format/dotnet-format-integration.yml@self - parameters: - oneESCompat: - templateFolderName: templates-official - publishTaskPrefix: 1ES. - populateInternalRuntimeVariables: true - runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) - ############### PUBLISH STAGE ############### - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: - stage: publish diff --git a/.vsts-pr.yml b/.vsts-pr.yml index db1ae21f8fed..ceb6222ad86f 100644 --- a/.vsts-pr.yml +++ b/.vsts-pr.yml @@ -157,5 +157,3 @@ stages: macOSJobParameterSets: - runtimeIdentifier: osx-x64 - ############### DOTNET-FORMAT ############### - - template: /eng/dotnet-format/dotnet-format-integration.yml diff --git a/CODEOWNERS b/CODEOWNERS index b9ffd1b4c554..d9873922d382 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,8 +54,8 @@ /src/WasmSdk @lewing @akoeplinger @pavelsavara @maraf # Area-Format -/src/Cli/dotnet/Commands/Format @phil-allen-msft -/test/dotnet-format.UnitTests @phil-allen-msft +/src/Cli/dotnet/Commands/Format @mwiemer-microsoft +/test/dotnet-format.*Tests @mwiemer-microsoft # Area-NuGet /src/Cli/dotnet/Commands/NuGet @dotnet/nuget-team @@ -119,7 +119,7 @@ # Area-Format /src/BuiltInTools/dotnet-format @dotnet/roslyn-ide -/test/dotnet-format.UnitTests @dotnet/roslyn-ide +/test/dotnet-format.*Tests @dotnet/roslyn-ide # Area-Microsoft.CodeAnalysis.NetAnalyzers /src/Microsoft.CodeAnalysis.NetAnalyzers @dotnet/dotnet-analyzers diff --git a/eng/dotnet-format/dotnet-format-integration.yml b/eng/dotnet-format/dotnet-format-integration.yml deleted file mode 100644 index 8df9e7fe7b05..000000000000 --- a/eng/dotnet-format/dotnet-format-integration.yml +++ /dev/null @@ -1,123 +0,0 @@ -parameters: -- name: oneESCompat - type: object - default: - templateFolderName: templates - publishTaskPrefix: '' - -- name: TestArguments - type: object - default: - # Commit SHAs align with the .NET 10.0 GA release (v10.0.100). - # See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json - - Name: Roslyn - _repo: "https://github.com/dotnet/roslyn" - _repoName: "dotnet/roslyn" - _targetSolution: "Compilers.slnf" - _branchName: "main" - _sha: "739dc0e352a331e8a41cd66c09d2edf359255365" - _useParentSdk: 0 - - Name: sdk - _repo: "https://github.com/dotnet/sdk" - _repoName: "dotnet/sdk" - _targetSolution: "sdk.slnx" - _branchName: "main" - _sha: "e6bc966cc3d1348265b0831c6daca23267169d8f" - _useParentSdk: 0 - - Name: project_system - _repo: "https://github.com/dotnet/project-system" - _repoName: "dotnet/project-system" - _targetSolution: "ProjectSystem.sln" - _branchName: "main" - _sha: "e660d54d6b3198751bd0502fe270e1657f32a913" - _useParentSdk: 1 - - Name: msbuild - _repo: "https://github.com/dotnet/msbuild" - _repoName: "dotnet/msbuild" - _targetSolution: "MSBuild.sln" - _branchName: "main" - _sha: "995a3dce41788caebf2b8ca6602a7431f08bfd06" - _useParentSdk: 0 - - Name: aspnetcore - _repo: "https://github.com/dotnet/aspnetcore" - _repoName: "dotnet/aspnetcore" - _targetSolution: "AspNetCore.slnx" - _branchName: "main" - _sha: "7387de91234d3ef751fa50b3d1bfede4130213ff" - _useParentSdk: 0 - - Name: efcore - _repo: "https://github.com/dotnet/efcore" - _repoName: "dotnet/efcore" - _targetSolution: "EFCore.sln" - _branchName: "main" - _sha: "12b8d44bf691d2e6933a6d1003647cce4f13c3d3" - _useParentSdk: 0 - -- name: timeoutInMinutes - type: number - default: 90 -- name: runtimeSourceProperties - type: string - default: '' -- name: populateInternalRuntimeVariables - type: boolean - default: false - -jobs: -- job: Formatting_Check - displayName: Run Formatting Check - pool: - ${{ if eq(variables['System.TeamProject'], 'public') }}: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals 1es-windows-2022-open - ${{ if eq(variables['System.TeamProject'], 'internal') }}: - name: $(DncEngInternalBuildPool) - image: 1es-windows-2022 - os: windows - timeoutInMinutes: ${{ parameters.timeoutInMinutes }} - steps: - - ${{ if eq(parameters.populateInternalRuntimeVariables, true) }}: - - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-sources.yml - parameters: - legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-runtimes.yml - - script: .\restore.cmd ${{ parameters.runtimeSourceProperties }} - displayName: 🟣 Restore dependencies - - script: | - .\artifacts\sdk-build-env.bat - dotnet run --project .\src\Dotnet.Format\dotnet-format\dotnet-format.csproj -c Release -- @eng\dotnet-format\validate.rsp - displayName: 🟣 Run dotnet-format - - task: ${{ parameters.oneESCompat.publishTaskPrefix }}PublishBuildArtifacts@1 - displayName: 🟣 Publish Logs - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory) - ArtifactName: dotnet-format formatting Check - continueOnError: true - condition: not(succeeded()) - -- ${{ each testArgs in parameters.TestArguments }}: - - job: dotnet_format_integration_tests_${{ testArgs.Name }} - displayName: 'Dotnet-Format Integration Tests: ${{ testArgs.Name }}' - pool: - ${{ if eq(variables['System.TeamProject'], 'public') }}: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals 1es-windows-2022-open - ${{ if eq(variables['System.TeamProject'], 'internal') }}: - name: $(DncEngInternalBuildPool) - image: 1es-windows-2022 - os: windows - timeoutInMinutes: ${{ parameters.timeoutInMinutes }} - steps: - - ${{ if eq(parameters.populateInternalRuntimeVariables, true) }}: - - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-sources.yml - parameters: - legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-runtimes.yml - - script: eng\dotnet-format\integration-test.cmd -repo '${{ testArgs._repo }}' -branchName '${{ testArgs._branchName }}' -sha '${{ testArgs._sha }}' -targetSolution '${{ testArgs._targetSolution }}' -useParentSdk ${{ testArgs._useParentSdk }} -testPath '$(Agent.TempDirectory)\temp' -stage 'prepare' -runtimeSourceProperties '${{ parameters.runtimeSourceProperties }}' - displayName: 🟣 Prepare ${{ testArgs._repoName }} for formatting - - - script: eng\dotnet-format\integration-test.cmd -repo '${{ testArgs._repo }}' -branchName '${{ testArgs._branchName }}' -sha '${{ testArgs._sha }}' -targetSolution '${{ testArgs._targetSolution }}' -useParentSdk ${{ testArgs._useParentSdk }} -testPath '$(Agent.TempDirectory)\temp' -stage 'format-workspace' - displayName: 🟣 Run dotnet-format on ${{ testArgs._repoName }} ${{ testArgs._targetSolution }} - - - script: eng\dotnet-format\integration-test.cmd -repo '${{ testArgs._repo }}' -branchName '${{ testArgs._branchName }}' -sha '${{ testArgs._sha }}' -targetSolution '${{ testArgs._targetSolution }}' -useParentSdk ${{ testArgs._useParentSdk }} -testPath '$(Agent.TempDirectory)\temp' -stage 'format-folder' - displayName: 🟣 Run dotnet-format on ${{ testArgs._repoName }} repo folder diff --git a/eng/dotnet-format/format-verifier.ps1 b/eng/dotnet-format/format-verifier.ps1 deleted file mode 100644 index c165a5dd7f22..000000000000 --- a/eng/dotnet-format/format-verifier.ps1 +++ /dev/null @@ -1,138 +0,0 @@ -[CmdletBinding(PositionalBinding = $false)] -Param( - [string]$repo, - [string]$sha, - [string]$branchName, - [string]$targetSolution, - [string]$runtimeSourceProperties, - [bool]$useParentSdk, - [string]$testPath, - [string]$stage # Valid values are "prepare", "format-workspace", "format-folder" -) - -if ($stage -eq "prepare") { - Write-Output "$(Get-Date) - Building dotnet-format." - .\build.cmd -c Release $runtimeSourceProperties -} - -$currentLocation = Get-Location -$dotnetPath = Join-Path $currentLocation ".dotnet" -$parentDotNetPath = Join-Path $dotnetPath "dotnet.exe" - -if (!(Test-Path $testPath)) { - New-Item -ItemType Directory -Force -Path $testPath | Out-Null -} - -try { - $repoName = $repo.Substring(19) - $folderName = $repoName.Split("/")[1] - $repoPath = Join-Path $testPath $folderName - $dllPath = Get-ChildItem -Path "$currentLocation/artifacts/bin/dotnet-format/Release/" -Include dotnet-format.dll -Recurse - - if (!(Test-Path $repoPath)) { - New-Item -ItemType Directory -Force -Path $repoPath | Out-Null - } - - Set-Location $repoPath - - if ($stage -eq "prepare") { - Write-Output "$(Get-Date) - Cloning $repoName." - git.exe init - git.exe remote add origin $repo - git.exe fetch --progress --no-tags --depth=1 origin $sha - git.exe checkout $sha - } - - # We invoke build.ps1 ourselves because running `restore.cmd` invokes the build.ps1 - # in a child process which means added .NET Core SDKs aren't visible to this process. - if (Test-Path '.\eng\Build.ps1') { - Write-Output "$(Get-Date) - Running Build.ps1 -restore" - .\eng\Build.ps1 -restore - } - elseif (Test-Path '.\eng\common\Build.ps1') { - Write-Output "$(Get-Date) - Running Build.ps1 -restore" - .\eng\common\Build.ps1 -restore - } - - if ($stage -eq "prepare" -or $stage -eq "format-workspace") { - Write-Output "$(Get-Date) - Finding solutions." - $solutions = Get-ChildItem -Include *.sln,*.slnf,*.slnx -Recurse -Depth 2 | Select-Object -ExpandProperty FullName - - $solutionFound = $false - foreach ($solution in $solutions) { - $solutionPath = Split-Path $solution - $solutionFile = Split-Path $solution -leaf - - if ($solutionFile -ne $targetSolution) { - continue - } - - $solutionFound = $true - Set-Location $solutionPath - - if ($stage -eq "prepare") { - Write-Output "$(Get-Date) - $solutionFile - Restoring" - - # Restore the solution either with the repo local sdk or optionally with the dotnet/format SDK. - if ($useParentSdk) { - & $parentDotNetPath restore $solution --configfile nuget.config - } else { - dotnet.exe restore $solution - } - } - - if ($stage -eq "format-workspace") { - Write-Output "$(Get-Date) - $solutionFile - Formatting Workspace" - $output = & $parentDotNetPath "$dllPath" $solution --no-restore -v diag --verify-no-changes | Out-String - Write-Output $output.TrimEnd() - - # Ignore CheckFailedExitCode since we don't expect these repos to be properly formatted. - if ($LastExitCode -ne 0 -and $LastExitCode -ne 2) { - Write-Output "$(Get-Date) - Formatting failed with error code $LastExitCode." - exit -1 - } - - if (($output -notmatch "(?m)Formatted \d+ of (\d+) files") -or ($Matches[1] -eq "0")) { - Write-Output "$(Get-Date) - No files found for solution." - # The dotnet/sdk has a toolset solution with no files. - # exit -1 - } - } - - Write-Output "$(Get-Date) - $solutionFile - Complete" - } - - if (-not $solutionFound) { - $solutionNames = $solutions | ForEach-Object { Split-Path $_ -Leaf } - Write-Output "$(Get-Date) - Target solution '$targetSolution' was not found. Available solutions ($($solutionNames.Count)): $($solutionNames -join ', ')" - exit -1 - } - } - - if ($stage -eq "format-folder") { - Write-Output "$(Get-Date) - $folderName - Formatting Folder" - $output = & $parentDotNetPath "$dllPath" whitespace $repoPath --folder -v diag --verify-no-changes | Out-String - Write-Output $output.TrimEnd() - - # Ignore CheckFailedExitCode since we don't expect these repos to be properly formatted. - if ($LastExitCode -ne 0 -and $LastExitCode -ne 2) { - Write-Output "$(Get-Date) - Formatting failed with error code $LastExitCode." - exit -1 - } - - if (($output -notmatch "(?m)Formatted \d+ of (\d+) files") -or ($Matches[1] -eq "0")) { - Write-Output "$(Get-Date) - No files found for solution." - exit -1 - } - - Write-Output "$(Get-Date) - $folderName - Complete" - } - - exit 0 -} -catch { - exit -1 -} -finally { - Set-Location $currentLocation -} diff --git a/eng/dotnet-format/integration-test.cmd b/eng/dotnet-format/integration-test.cmd deleted file mode 100644 index bb23395fc926..000000000000 --- a/eng/dotnet-format/integration-test.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0format-verifier.ps1""" %*" -exit /b %ErrorLevel% \ No newline at end of file diff --git a/eng/dotnet-format/validate.rsp b/eng/dotnet-format/validate.rsp deleted file mode 100644 index d38768e941ab..000000000000 --- a/eng/dotnet-format/validate.rsp +++ /dev/null @@ -1,8 +0,0 @@ -./src/Dotnet.Format/dotnet-format.slnf ---exclude -./tests/projects/ ---verify-no-changes ---report -./artifacts/log/ --v -diag diff --git a/sdk.slnx b/sdk.slnx index 8da3d7601ec6..2fabaae4c973 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -349,6 +349,7 @@ + diff --git a/src/Dotnet.Format/dotnet-format.slnf b/src/Dotnet.Format/dotnet-format.slnf index 6f3ae502a927..78140c7a00ce 100644 --- a/src/Dotnet.Format/dotnet-format.slnf +++ b/src/Dotnet.Format/dotnet-format.slnf @@ -3,7 +3,8 @@ "path": "..\\..\\sdk.slnx", "projects": [ "src\\Dotnet.Format\\dotnet-format\\dotnet-format.csproj", - "test\\dotnet-format.UnitTests\\dotnet-format.UnitTests.csproj" + "test\\dotnet-format.UnitTests\\dotnet-format.UnitTests.csproj", + "test\\dotnet-format.IntegrationTests\\dotnet-format.IntegrationTests.csproj" ] } } diff --git a/test/ConditionalTests.props b/test/ConditionalTests.props index e89ed62fa14a..14f96ae750be 100644 --- a/test/ConditionalTests.props +++ b/test/ConditionalTests.props @@ -45,18 +45,16 @@ CI - + project - test/TemplateEngine/**/*.csproj + test/dotnet-format.*/**/*.csproj - documentation/TemplateEngine/Samples/**; - src/TemplateEngine/**; - template_feed/Microsoft.TemplateEngine.Authoring.Templates/**; - test/TemplateEngine/**; - test/TestAssets/TestPackages/TemplateEngine/** + src/Dotnet.Format/**; + test/dotnet-format.*/** CI + project src/Microsoft.CodeAnalysis.NetAnalyzers/tests/**/*.csproj @@ -65,6 +63,19 @@ CI + + + project + test/TemplateEngine/**/*.csproj + + documentation/TemplateEngine/Samples/**; + src/TemplateEngine/**; + template_feed/Microsoft.TemplateEngine.Authoring.Templates/**; + test/TemplateEngine/**; + test/TestAssets/TestPackages/TemplateEngine/** + + CI + diff --git a/test/UnitTests.proj b/test/UnitTests.proj index a99d82d87543..9959c5ff9be7 100644 --- a/test/UnitTests.proj +++ b/test/UnitTests.proj @@ -28,9 +28,18 @@ - + + + + + diff --git a/test/dotnet-format.IntegrationTests/FormatIntegrationTestBase.cs b/test/dotnet-format.IntegrationTests/FormatIntegrationTestBase.cs new file mode 100644 index 000000000000..7bb688319e9e --- /dev/null +++ b/test/dotnet-format.IntegrationTests/FormatIntegrationTestBase.cs @@ -0,0 +1,311 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Microsoft.DotNet.Format.IntegrationTests; + +/// +/// Base class for dotnet-format integration tests. Each derived class targets a specific +/// repository and gets its own clone+restore lifecycle via . +/// Two test methods (workspace format and folder format) are inherited automatically. +/// +public abstract class FormatIntegrationTestBase : SdkTest +{ + private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(30); + + // Keyed by concrete type so each derived class gets its own entry. + // MSTest creates a new instance per test method, so clone+restore would otherwise + // run once per test method rather than once per class per process. + private static readonly Dictionary s_initializedRepoPaths = new(); + + private string? _repoPath; + + private string ParentDotNetPath => SdkTestContext.Current.ToolsetUnderTest.DotNetHostPath; + + /// The full GitHub URL of the repository (e.g. "https://github.com/dotnet/sdk"). + protected abstract string RepoUrl { get; } + + /// The commit SHA to test against. + protected abstract string Sha { get; } + + /// The solution/slnf/slnx file name to format (e.g. "sdk.slnx"). + protected abstract string TargetSolution { get; } + + /// Short display name for the repo (e.g. "sdk"). Used for filtering and logging. + protected abstract string RepoName { get; } + + [TestInitialize] + public void TestInitialize() + { + if (s_initializedRepoPaths.TryGetValue(GetType(), out var cachedPath)) + { + _repoPath = cachedPath; + Log.WriteLine($"Reusing initialized repo for {RepoName} at {_repoPath}"); + return; + } + + _repoPath = Path.Combine(Path.GetTempPath(), "dotnet-format-tests", RepoName); + + if (IsAlreadyAtCorrectSha()) + { + Log.WriteLine($"Reusing existing clone at {Sha}"); + } + else + { + if (Directory.Exists(_repoPath)) + { + Directory.Delete(_repoPath, recursive: true); + } + + Directory.CreateDirectory(_repoPath); + + CloneRepo(); + + // Run the repo's Arcade build script restore while global.json is still present. + // This installs the Arcade SDK tooling that project files import (e.g., + // $(ArcadeSdkBuildTasksAssembly)). Without this, repos like Roslyn fail to restore + // because their project files have hard imports of Arcade targets. + RunArcadeBuildScriptRestore(); + } + + // Strip the "sdk" section from global.json so that the SDK under test is used + // for solution restore and formatting. The "msbuild-sdks" section is preserved + // so that NuGet SDK resolver can still find Arcade and other MSBuild SDKs. + RemoveGlobalJsonSdkSection(); + + Restore(); + + s_initializedRepoPaths[GetType()] = _repoPath; + } + + [TestMethod] + public void FormatWorkspace() + { + var solutionPath = FindSolution(); + Log.WriteLine($"Formatting workspace: {solutionPath}"); + + var result = new DotnetCommand(Log, "format", solutionPath, "--no-restore", "--verify-no-changes", "--verbosity", "detailed") + .WithWorkingDirectory(Path.GetDirectoryName(solutionPath)!) + .Execute(); + + // Exit code 0 = no changes needed, 2 = format differences found (expected for external repos). + // Any other exit code is an actual failure. + Assert.IsTrue(result.ExitCode == 0 || result.ExitCode == 2, + $"dotnet format exited with unexpected code {result.ExitCode}.\n{result.StdErr}"); + + // Validate that dotnet format actually processed files. Without this check, a broken + // restore can cause format to silently exit 0 having loaded zero projects. + var match = Regex.Match(result.StdOut ?? "", @"Formatted \d+ of (\d+) files"); + Assert.IsTrue(match.Success && int.Parse(match.Groups[1].Value) > 0, + $"dotnet format did not report processing any files. Restore may have failed.\nStdOut: {result.StdOut}"); + } + + [TestMethod] + public void FormatFolder() + { + Log.WriteLine($"Formatting folder: {_repoPath}"); + + var result = new DotnetCommand(Log, "format", "whitespace", _repoPath!, "--folder", "--verify-no-changes", "--verbosity", "detailed") + .WithWorkingDirectory(_repoPath!) + .Execute(); + + Assert.IsTrue(result.ExitCode == 0 || result.ExitCode == 2, + $"dotnet format exited with unexpected code {result.ExitCode}.\n{result.StdErr}"); + + var match = Regex.Match(result.StdOut ?? "", @"Formatted \d+ of (\d+) files"); + Assert.IsTrue(match.Success && int.Parse(match.Groups[1].Value) > 0, + $"dotnet format did not report processing any files.\nStdOut: {result.StdOut}"); + } + + private bool IsAlreadyAtCorrectSha() + { + if (!Directory.Exists(_repoPath)) + { + return false; + } + + var result = RunProcess("git", "rev-parse HEAD", ignoreThrowingOnError: true); + return result.ExitStatus.ExitCode == 0 && + string.Equals(result.StandardOutput.Trim(), Sha, StringComparison.OrdinalIgnoreCase); + } + + private void CloneRepo() + { + Log.WriteLine($"Cloning {RepoUrl} at {Sha}..."); + var sw = Stopwatch.StartNew(); + + RunProcess("git", "init"); + RunProcess("git", $"remote add origin {RepoUrl}"); + RunProcess("git", $"fetch --progress --no-tags --depth=1 origin {Sha}"); + RunProcess("git", $"checkout {Sha}"); + + sw.Stop(); + Log.WriteLine($"Clone completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}"); + } + + /// + /// Runs the repo's Arcade build script with -restore to install Arcade SDK tooling. + /// This must run while global.json is still present so that Arcade's install scripts + /// can acquire the correct SDK version. The installed tooling (targets, tasks) is then + /// available for the subsequent solution-level restore. + /// Failures are non-fatal — repos without Arcade (or with inaccessible feeds) still + /// proceed to the direct solution restore. + /// + private void RunArcadeBuildScriptRestore() + { + string scriptPath; + string arguments; + + if (OperatingSystem.IsWindows()) + { + var engBuild = Path.Combine(_repoPath!, "eng", "Build.ps1"); + var engCommonBuild = Path.Combine(_repoPath!, "eng", "common", "Build.ps1"); + + if (File.Exists(engBuild)) + { + scriptPath = "powershell"; + arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{engBuild}\" -restore"; + } + else if (File.Exists(engCommonBuild)) + { + scriptPath = "powershell"; + arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{engCommonBuild}\" -restore"; + } + else + { + Log.WriteLine("No Arcade build script found; skipping Arcade restore."); + return; + } + } + else + { + var engBuildSh = Path.Combine(_repoPath!, "eng", "build.sh"); + var engCommonBuildSh = Path.Combine(_repoPath!, "eng", "common", "build.sh"); + + if (File.Exists(engBuildSh)) + { + scriptPath = "bash"; + arguments = $"\"{engBuildSh}\" --restore"; + } + else if (File.Exists(engCommonBuildSh)) + { + scriptPath = "bash"; + arguments = $"\"{engCommonBuildSh}\" --restore"; + } + else + { + Log.WriteLine("No Arcade build script found; skipping Arcade restore."); + return; + } + } + + Log.WriteLine($"Running Arcade build script restore: {scriptPath} {arguments}"); + var sw = Stopwatch.StartNew(); + + RunProcess(scriptPath, arguments, ignoreThrowingOnError: true); + + sw.Stop(); + Log.WriteLine($"Arcade restore completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}"); + } + + /// + /// Strips the "sdk" section from the repo's global.json so that the SDK under test + /// is used for restore and formatting, while preserving the "msbuild-sdks" section + /// that tells the NuGet SDK resolver which versions of MSBuild SDKs + /// (e.g., Microsoft.DotNet.Arcade.Sdk) to use. + /// + private void RemoveGlobalJsonSdkSection() + { + var globalJsonPath = Path.Combine(_repoPath!, "global.json"); + if (!File.Exists(globalJsonPath)) + { + return; + } + + var json = JsonNode.Parse(File.ReadAllText(globalJsonPath)); + if (json is JsonObject obj && obj.ContainsKey("sdk")) + { + obj.Remove("sdk"); + File.WriteAllText(globalJsonPath, obj.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + Log.WriteLine("Removed 'sdk' section from global.json to use parent SDK."); + } + } + + private void Restore() + { + var sw = Stopwatch.StartNew(); + + var solutionPath = FindSolution(); + RestoreWithParentSdk(solutionPath); + + sw.Stop(); + Log.WriteLine($"Restore completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}"); + } + + /// + /// Restores the solution using the SDK under test's dotnet. + /// NuGet auto-discovers NuGet.config by walking up from the solution file's directory, + /// so no explicit --configfile is needed. + /// Failures are logged but not fatal — some feeds may require authentication that + /// is unavailable in public CI environments. + /// + private void RestoreWithParentSdk(string solutionPath) + { + var solutionDir = Path.GetDirectoryName(solutionPath)!; + Log.WriteLine($"Restoring {Path.GetFileName(solutionPath)} with SDK under test: {ParentDotNetPath}"); + + RunProcess(ParentDotNetPath, $"restore \"{solutionPath}\"", ignoreThrowingOnError: true, workingDirectory: solutionDir); + } + + /// + /// Runs a process and captures its stdout and stderr. Throws on timeout. + /// When is false (default), also throws on non-zero exit code. + /// + private ProcessTextOutput RunProcess(string fileName, string arguments, bool ignoreThrowingOnError = false, string? workingDirectory = null) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory ?? _repoPath!, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + var result = Process.RunAndCaptureText(psi, ProcessTimeout); + + if (result.ExitStatus.ExitCode != 0) + { + Log.WriteLine(result.StandardOutput); + Log.WriteLine(result.StandardError); + + if (!ignoreThrowingOnError) + { + throw new InvalidOperationException($"Command '{fileName} {arguments}' exited with code {result.ExitStatus.ExitCode}"); + } + + Log.WriteLine($"Warning: Command '{fileName} {arguments}' exited with code {result.ExitStatus.ExitCode} (non-fatal)."); + } + + return result; + } + + private string FindSolution() + { + // Match the old script's -Recurse -Depth 2 behavior to avoid unnecessary deep traversal. + var options = new EnumerationOptions { RecurseSubdirectories = true, MaxRecursionDepth = 2 }; + var matches = Directory.GetFiles(_repoPath!, TargetSolution, options); + Assert.IsNotEmpty(matches, $"Target solution '{TargetSolution}' not found under {_repoPath}"); + + if (matches.Length > 1) + { + Log.WriteLine($"Warning: multiple matches for '{TargetSolution}' under {_repoPath}: {string.Join(", ", matches)}. Using root-most match."); + } + + return matches.MinBy(m => m.Length)!; + } +} diff --git a/test/dotnet-format.IntegrationTests/MsBuildFormatTests.cs b/test/dotnet-format.IntegrationTests/MsBuildFormatTests.cs new file mode 100644 index 000000000000..9525954bc031 --- /dev/null +++ b/test/dotnet-format.IntegrationTests/MsBuildFormatTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Format.IntegrationTests; + +/// +/// Format integration tests for the dotnet/msbuild repository. +/// +[TestClass] +public class MsBuildFormatTests : FormatIntegrationTestBase +{ + // Commit SHA aligned with .NET 10.0 GA release (v10.0.100). + // See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json + + protected override string RepoUrl => "https://github.com/dotnet/msbuild"; + protected override string Sha => "995a3dce41788caebf2b8ca6602a7431f08bfd06"; + protected override string TargetSolution => "MSBuild.sln"; + protected override string RepoName => "msbuild"; +} diff --git a/test/dotnet-format.IntegrationTests/ProjectSystemFormatTests.cs b/test/dotnet-format.IntegrationTests/ProjectSystemFormatTests.cs new file mode 100644 index 000000000000..4cd0c08bbd7f --- /dev/null +++ b/test/dotnet-format.IntegrationTests/ProjectSystemFormatTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Format.IntegrationTests; + +/// +/// Format integration tests for the dotnet/project-system repository. +/// +[TestClass] +public class ProjectSystemFormatTests : FormatIntegrationTestBase +{ + // Commit SHA aligned with .NET 10.0 GA release (v10.0.100). + // See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json + + protected override string RepoUrl => "https://github.com/dotnet/project-system"; + protected override string Sha => "e660d54d6b3198751bd0502fe270e1657f32a913"; + protected override string TargetSolution => "ProjectSystem.sln"; + protected override string RepoName => "project-system"; +} diff --git a/test/dotnet-format.IntegrationTests/SdkFormatTests.cs b/test/dotnet-format.IntegrationTests/SdkFormatTests.cs new file mode 100644 index 000000000000..bf2424f3a5f5 --- /dev/null +++ b/test/dotnet-format.IntegrationTests/SdkFormatTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Format.IntegrationTests; + +/// +/// Format integration tests for the dotnet/sdk repository. +/// +[TestClass] +public class SdkFormatTests : FormatIntegrationTestBase +{ + // Commit SHA aligned with .NET 10.0 GA release (v10.0.100). + // See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json + + protected override string RepoUrl => "https://github.com/dotnet/sdk"; + protected override string Sha => "e6bc966cc3d1348265b0831c6daca23267169d8f"; + protected override string TargetSolution => "sdk.slnx"; + protected override string RepoName => "sdk"; +} diff --git a/test/dotnet-format.IntegrationTests/dotnet-format.IntegrationTests.csproj b/test/dotnet-format.IntegrationTests/dotnet-format.IntegrationTests.csproj new file mode 100644 index 000000000000..31f430a5c236 --- /dev/null +++ b/test/dotnet-format.IntegrationTests/dotnet-format.IntegrationTests.csproj @@ -0,0 +1,13 @@ + + + + $(SdkTargetFramework) + + None + + + + + + +