diff --git a/eng/packages/http-client-csharp-mgmt/eng/scripts/Generate.ps1 b/eng/packages/http-client-csharp-mgmt/eng/scripts/Generate.ps1 index 69297ce9b26b..fffbb0c8462e 100755 --- a/eng/packages/http-client-csharp-mgmt/eng/scripts/Generate.ps1 +++ b/eng/packages/http-client-csharp-mgmt/eng/scripts/Generate.ps1 @@ -7,9 +7,10 @@ param( ) Import-Module "$PSScriptRoot\Generation.psm1" -DisableNameChecking -Force; +Import-Module "$PSScriptRoot\Spector-Helper.psm1" -DisableNameChecking -Force; $mgmtPackageRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') -Write-Host "Mgmt Package root: $packageRoot" -ForegroundColor Cyan +Write-Host "Mgmt Package root: $mgmtPackageRoot" -ForegroundColor Cyan $mgmtSolutionDir = Join-Path $mgmtPackageRoot 'generator' if (-not $LaunchOnly) { @@ -38,6 +39,43 @@ if (-not $LaunchOnly) { } } +$spectorRoot = Join-Path $mgmtPackageRoot 'generator' 'TestProjects' 'Spector' + +$spectorLaunchProjects = @{} + +foreach ($specFile in Get-Sorted-Specs) { + $subPath = Get-SubPath $specFile + $folders = $subPath.Split([System.IO.Path]::DirectorySeparatorChar) + + if (-not (Compare-Paths $subPath $filter)) { + continue + } + + $generationDir = $spectorRoot + foreach ($folder in $folders) { + $generationDir = Join-Path $generationDir $folder + } + + # create the directory if it doesn't exist + if (-not (Test-Path $generationDir)) { + New-Item -ItemType Directory -Path $generationDir | Out-Null + } + + Write-Host "Generating $subPath" -ForegroundColor Cyan + + $spectorLaunchProjects.Add(($folders -join "-"), ("TestProjects/Spector/$($subPath.Replace([System.IO.Path]::DirectorySeparatorChar, '/'))")) + if ($LaunchOnly) { + continue + } + + Invoke (Get-Mgmt-TspCommand $specFile $generationDir -debug:$Debug) + + # exit if the generation failed + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} + # only write new launch settings if no filter was passed in if ($null -eq $filter) { $mgmtSpec = "TestProjects/Local/Mgmt-TypeSpec" @@ -50,6 +88,13 @@ if ($null -eq $filter) { $mgmtLaunchSettings["profiles"]["Mgmt-TypeSpec"].Add("commandName", "Executable") $mgmtLaunchSettings["profiles"]["Mgmt-TypeSpec"].Add("executablePath", "dotnet") + foreach ($kvp in $spectorLaunchProjects.GetEnumerator()) { + $mgmtLaunchSettings["profiles"].Add($kvp.Key, @{}) + $mgmtLaunchSettings["profiles"][$kvp.Key].Add("commandLineArgs", "`$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll `$(SolutionDir)/$($kvp.Value) -g MgmtStubGenerator") + $mgmtLaunchSettings["profiles"][$kvp.Key].Add("commandName", "Executable") + $mgmtLaunchSettings["profiles"][$kvp.Key].Add("executablePath", "dotnet") + } + $mgmtSortedLaunchSettings = @{} $mgmtSortedLaunchSettings.Add("profiles", [ordered]@{}) $mgmtLaunchSettings["profiles"].Keys | Sort-Object | ForEach-Object { diff --git a/eng/packages/http-client-csharp-mgmt/eng/scripts/Generation.psm1 b/eng/packages/http-client-csharp-mgmt/eng/scripts/Generation.psm1 index 369dd8ddd684..2b1d7b12d840 100644 --- a/eng/packages/http-client-csharp-mgmt/eng/scripts/Generation.psm1 +++ b/eng/packages/http-client-csharp-mgmt/eng/scripts/Generation.psm1 @@ -37,9 +37,6 @@ function Get-Mgmt-TspCommand { } $command += " --option @azure-typespec/http-client-csharp-mgmt.emitter-output-dir=$generationDir" $command += " --option @azure-typespec/http-client-csharp-mgmt.save-inputs=true" - if ($generateStub) { - $command += " --option @azure-typespec/http-client-csharp-mgmt.plugin-name=AzureStubPlugin" - } if ($apiVersion) { $command += " --option @azure-typespec/http-client-csharp-mgmt.api-version=$apiVersion" diff --git a/eng/packages/http-client-csharp-mgmt/eng/scripts/Spector-Helper.psm1 b/eng/packages/http-client-csharp-mgmt/eng/scripts/Spector-Helper.psm1 new file mode 100644 index 000000000000..e3dc2fe6b13a --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/eng/scripts/Spector-Helper.psm1 @@ -0,0 +1,113 @@ +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + +# These are specs that are not yet building correctly with the management generator +# Add specs here as needed when they fail to build +$failingSpecs = @( +) + +function Capitalize-FirstLetter { + param ( + [string]$inputString + ) + + if ([string]::IsNullOrEmpty($inputString)) { + return $inputString + } + + $firstChar = $inputString[0].ToString().ToUpper() + $restOfString = $inputString.Substring(1) + + return $firstChar + $restOfString +} + +function Get-Namespace { + param ( + [string]$dir + ) + + $words = $dir.Split('-') + $namespace = "" + foreach ($word in $words) { + $namespace += Capitalize-FirstLetter $word + } + return $namespace +} + +function IsValidSpecDir { + param ( + [string]$fullPath + ) + if (-not(Test-Path "$fullPath/main.tsp")){ + return $false; + } + + $subPath = Get-SubPath $fullPath + + if ($failingSpecs.Contains($subPath)) { + Write-Host "Skipping $subPath" -ForegroundColor Yellow + return $false + } + + return $true +} + +function Get-Azure-Specs-Directory { + $packageRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') + return Join-Path $packageRoot 'node_modules' '@azure-tools' 'azure-http-specs' +} + +function Get-Sorted-Specs { + $azureSpecsDirectory = Get-Azure-Specs-Directory + + # Only get azure resource-manager specs + $resourceManagerPath = Join-Path $azureSpecsDirectory "specs" "azure" "resource-manager" + $directories = @(Get-ChildItem -Path $resourceManagerPath -Directory -Recurse) + + $sep = [System.IO.Path]::DirectorySeparatorChar + $pattern = "${sep}specs${sep}" + + return $directories | Where-Object { IsValidSpecDir $_.FullName } | ForEach-Object { + + # Pick client.tsp if it exists, otherwise main.tsp + $specFile = Join-Path $_.FullName "client.tsp" + if (-not (Test-Path $specFile)) { + $specFile = Join-Path $_.FullName "main.tsp" + } + + # Extract the relative path after "specs/" and normalize slashes + $relativePath = ($specFile -replace '[\\\/]', '/').Substring($_.FullName.IndexOf($pattern) + $pattern.Length) + + # Remove the filename to get just the directory path + $dirPath = $relativePath -replace '/[^/]+\.tsp$', '' + + # Produce an object with the path for sorting + [PSCustomObject]@{ + SpecFile = $specFile + DirPath = $dirPath + } + } | Sort-Object -Property @{Expression = { $_.DirPath -replace '/', '!' }; Ascending = $true} | ForEach-Object { $_.SpecFile } +} + +function Get-SubPath { + param ( + [string]$fullPath + ) + $azureSpecsDirectory = Get-Azure-Specs-Directory + + $subPath = $fullPath.Substring($azureSpecsDirectory.Length + 1) + + # Keep consistent with the previous folder name because 'http' makes more sense then current 'specs' + $subPath = $subPath -replace '^specs', 'http' + + # also strip off the spec file name if present + $leaf = Split-Path -Leaf $subPath + if ($leaf -like '*.tsp') { + return (Split-Path $subPath) + } + + return $subPath +} + +Export-ModuleMember -Function "Get-Namespace" +Export-ModuleMember -Function "Get-Sorted-Specs" +Export-ModuleMember -Function "Get-SubPath" diff --git a/eng/packages/http-client-csharp-mgmt/eng/scripts/Test-Spector.ps1 b/eng/packages/http-client-csharp-mgmt/eng/scripts/Test-Spector.ps1 new file mode 100644 index 000000000000..fd38561df1fb --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/eng/scripts/Test-Spector.ps1 @@ -0,0 +1,82 @@ +#Requires -Version 7.0 + +param($filter) + +Import-Module "$PSScriptRoot\Generation.psm1" -DisableNameChecking -Force; +Import-Module "$PSScriptRoot\Spector-Helper.psm1" -DisableNameChecking -Force; + +$packageRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..') + +Refresh-Mgmt-Build + +$spectorRoot = Join-Path $packageRoot 'generator' 'TestProjects' 'Spector' +$spectorCsproj = Join-Path $packageRoot 'generator' 'TestProjects' 'Spector.Tests' 'TestProjects.Spector.Tests.csproj' + +$coverageDir = Join-Path $packageRoot 'generator' 'artifacts' 'coverage' + +if (-not (Test-Path $coverageDir)) { + New-Item -ItemType Directory -Path $coverageDir | Out-Null +} + +foreach ($specFile in Get-Sorted-Specs) { + $subPath = Get-SubPath $specFile + + # skip the HTTP root folder when computing the namespace filter + $folders = $subPath.Split([System.IO.Path]::DirectorySeparatorChar) | Select-Object -Skip 1 + + if (-not (Compare-Paths $subPath $filter)) { + continue + } + + $testPath = Join-Path "$spectorRoot.Tests" "Http" + $testFilter = "TestProjects.Spector.Tests.Http" + foreach ($folder in $folders) { + $segment = "$(Get-Namespace $folder)" + + # the test directory names match the test namespace names, but the source directory names will not have the leading underscore + # so check to see if the filter should contain a leading underscore by comparing with the test directory + if (-not (Test-Path (Join-Path $testPath $segment))) { + $testFilter += "._$segment" + $testPath = Join-Path $testPath "_$segment" + } + else{ + $testFilter += ".$segment" + $testPath = Join-Path $testPath $segment + } + } + + Write-Host "Regenerating $subPath" -ForegroundColor Cyan + + $outputDir = Join-Path $spectorRoot $subPath + + $command = Get-Mgmt-TspCommand $specFile $outputDir + Invoke $command + + # exit if the generation failed + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + Write-Host "Testing $subPath" -ForegroundColor Cyan + $command = "dotnet test $spectorCsproj --filter `"FullyQualifiedName~$testFilter`"" + Invoke $command + # exit if the testing failed + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + Write-Host "Restoring $subPath" -ForegroundColor Cyan + + $command = "git clean -xfd $outputDir" + Invoke $command + # exit if the restore failed + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $command = "git restore $outputDir" + Invoke $command + # exit if the restore failed + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/AssemblyCleanFixture.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/AssemblyCleanFixture.cs new file mode 100644 index 000000000000..3d5865a36078 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/AssemblyCleanFixture.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using NUnit.Framework; + +namespace TestProjects.Spector.Tests +{ + [SetUpFixture] + public static class AssemblyCleanFixture + { + [OneTimeTearDown] + public static void RunOnAssemblyCleanUp() + { + SpectorServerSession.Start().Server?.Dispose(); + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/BinaryDataAssert.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/BinaryDataAssert.cs new file mode 100644 index 000000000000..1ea9d6c6061c --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/BinaryDataAssert.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using NUnit.Framework; + +namespace TestProjects.Spector.Tests +{ + public static class BinaryDataAssert + { + public static void AreEqual(BinaryData expected, BinaryData result) + { + CollectionAssert.AreEqual(expected?.ToArray(), result?.ToArray()); + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/BuildPropertiesAttribute.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/BuildPropertiesAttribute.cs new file mode 100644 index 000000000000..2de3a28415d1 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/BuildPropertiesAttribute.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace TestProjects.Spector.Tests +{ + [AttributeUsage(AttributeTargets.Assembly)] + internal sealed class BuildPropertiesAttribute : Attribute + { + public string RepoRoot { get; } + public string ArtifactsDirectory { get; } + + public BuildPropertiesAttribute(string repoRoot, string artifactsDirectory) + { + RepoRoot = repoRoot; + ArtifactsDirectory = artifactsDirectory; + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelJsonTests.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelJsonTests.cs new file mode 100644 index 000000000000..c67e57f431ac --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelJsonTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using Azure.Generator.Management.Tests.Common; + +namespace TestProjects.Spector.Tests.Infrastructure +{ + public abstract class SpectorModelJsonTests : SpectorModelTests where T : IJsonModel + { + [SpectorTest] + public void RoundTripWithJsonInterfaceOfTWire() + => RoundTripTest("W", new JsonInterfaceStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceOfTJson() + => RoundTripTest("J", new JsonInterfaceStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceNonGenericWire() + => RoundTripTest("W", new JsonInterfaceAsObjectStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceNonGenericJson() + => RoundTripTest("J", new JsonInterfaceAsObjectStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceUtf8ReaderWire() + => RoundTripTest("W", new JsonInterfaceUtf8ReaderStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceUtf8ReaderJson() + => RoundTripTest("J", new JsonInterfaceUtf8ReaderStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceUtf8ReaderNonGenericWire() + => RoundTripTest("W", new JsonInterfaceUtf8ReaderAsObjectStrategy()); + + [SpectorTest] + public void RoundTripWithJsonInterfaceUtf8ReaderNonGenericJson() + => RoundTripTest("J", new JsonInterfaceUtf8ReaderAsObjectStrategy()); + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelTests.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelTests.cs new file mode 100644 index 000000000000..6bfc54b4fd72 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using Azure.Generator.Management.Tests.Common; + +namespace TestProjects.Spector.Tests.Infrastructure +{ + public abstract class SpectorModelTests : ModelTests where T : IPersistableModel + { + [SpectorTest] + public void RoundTripWithModelReaderWriterWire() + => RoundTripWithModelReaderWriterBase("W"); + + [SpectorTest] + public void RoundTripWithModelReaderWriterJson() + => RoundTripWithModelReaderWriterBase("J"); + + [SpectorTest] + public void RoundTripWithModelReaderWriterNonGenericWire() + => RoundTripWithModelReaderWriterNonGenericBase("W"); + + [SpectorTest] + public void RoundTripWithModelReaderWriterNonGenericJson() + => RoundTripWithModelReaderWriterNonGenericBase("J"); + + [SpectorTest] + public void RoundTripWithModelInterfaceWire() + => RoundTripWithModelInterfaceBase("W"); + + [SpectorTest] + public void RoundTripWithModelInterfaceJson() + => RoundTripWithModelInterfaceBase("J"); + + [SpectorTest] + public void RoundTripWithModelInterfaceNonGenericWire() + => RoundTripWithModelInterfaceNonGenericBase("W"); + + [SpectorTest] + public void RoundTripWithModelInterfaceNonGenericJson() + => RoundTripWithModelInterfaceNonGenericBase("J"); + + [SpectorTest] + public void RoundTripWithModelCast() + => RoundTripWithModelCastBase("W"); + + [SpectorTest] + public void ThrowsIfUnknownFormat() + => ThrowsIfUnknownFormatBase(); + + [SpectorTest] + public void ThrowsIfWireIsNotJson() + => ThrowsIfWireIsNotJsonBase(); + + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorServer.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorServer.cs new file mode 100644 index 000000000000..14175aa21105 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorServer.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace TestProjects.Spector.Tests +{ + public class SpectorServer : TestServerBase + { + public SpectorServer() : base(GetProcessPath(), $"serve {string.Join(" ", GetScenariosPaths())} --port 0 --coverageFile {GetCoverageFilePath()}") + { + } + + internal static string GetProcessPath() + { + var nodeModules = GetNodeModulesDirectory(); + return Path.Combine(nodeModules, "@typespec", "spector", "dist", "src", "cli", "cli.js"); + } + + internal static string GetAzureSpecDirectory() + { + var nodeModules = GetNodeModulesDirectory(); + return Path.Combine(nodeModules, "@azure-tools", "azure-http-specs"); + } + + internal static IEnumerable GetScenariosPaths() + { + yield return Path.Combine(GetAzureSpecDirectory(), "specs"); + } + + internal static string GetCoverageFilePath() + { + return Path.Combine(GetCoverageDirectory(), "tsp-spector-coverage-mgmt.json"); + } + + protected override void Stop(Process process) + { + Process.Start(new ProcessStartInfo("node", $"{GetProcessPath()} server stop --port {Port}")); + process.WaitForExit(); + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorServerSession.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorServerSession.cs new file mode 100644 index 000000000000..1b6086c9e84f --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorServerSession.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; + +namespace TestProjects.Spector.Tests +{ + public class SpectorServerSession : TestServerSessionBase + { + private SpectorServerSession() : base() + { + } + + public static SpectorServerSession Start() + { + var server = new SpectorServerSession(); + return server; + } + + public override ValueTask DisposeAsync() + { + Return(); + return new ValueTask(); + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorTestAttribute.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorTestAttribute.cs new file mode 100644 index 000000000000..51b1fda3b8bd --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorTestAttribute.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NUnit.Framework; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; + +namespace TestProjects.Spector.Tests +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + internal partial class SpectorTestAttribute : TestAttribute, IApplyToTest + { + [GeneratedRegex("(?<=[a-z])([A-Z])")] + private static partial Regex ToKebabCase(); + + public new void ApplyToTest(Test test) + { + string clientCodeDirectory = GetGeneratedDirectory(test); + + if (!Directory.Exists(clientCodeDirectory)) + { + // Not all spector scenarios use kebab-case directories, so try again without kebab-case. + clientCodeDirectory = GetGeneratedDirectory(test, false); + } + + var clientCsFile = GetClientCsFile(clientCodeDirectory); + + TestContext.Progress.WriteLine($"Checking if '{clientCsFile}' is a stubbed implementation."); + if (clientCsFile is null || IsLibraryStubbed(clientCsFile)) + { + SkipTest(test); + } + } + + private static bool IsLibraryStubbed(string clientCsFile) + { + SyntaxTree tree = CSharpSyntaxTree.ParseText(File.ReadAllText(clientCsFile)); + CompilationUnitSyntax root = tree.GetCompilationUnitRoot(); + + var constructors = root.DescendantNodes() + .OfType() + .ToList(); + + if (constructors.Count != 0) + { + ConstructorDeclarationSyntax? constructorWithMostParameters = constructors + .OrderByDescending(c => c.ParameterList.Parameters.Count) + .FirstOrDefault(); + + return constructorWithMostParameters?.ExpressionBody != null; + } + + return true; + } + + private static void SkipTest(Test test) + { + test.RunState = RunState.Ignored; + TestContext.Progress.WriteLine($"Test skipped because {test.FullName} is currently a stubbed implementation."); + test.Properties.Set(PropertyNames.SkipReason, $"Test skipped because {test.FullName} is currently a stubbed implementation."); + } + + private static string? GetClientCsFile(string clientCodeDirectory) + { + return Directory.GetFiles(clientCodeDirectory, "*.cs", SearchOption.TopDirectoryOnly) + .Where(f => f.EndsWith("Client.cs", StringComparison.Ordinal) && !f.EndsWith("RestClient.cs", StringComparison.Ordinal)) + .FirstOrDefault(); + } + + private static string GetGeneratedDirectory(Test test, bool kebabCaseDirectories = true) + { + var namespaceParts = test.FullName.Split('.').Skip(3); + namespaceParts = namespaceParts.Take(namespaceParts.Count() - 2); + var clientCodeDirectory = Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "..", "eng", "packages", "http-client-csharp-mgmt", "generator", "TestProjects", "Spector"); + foreach (var part in namespaceParts) + { + clientCodeDirectory = Path.Combine(clientCodeDirectory, FixName(part, kebabCaseDirectories)); + } + return Path.Combine(clientCodeDirectory, "src", "Generated"); + } + + private static string FixName(string part, bool kebabCaseDirectories) + { + if (kebabCaseDirectories) + { + return ToKebabCase().Replace(part.StartsWith("_", StringComparison.Ordinal) ? part.Substring(1) : part, "-$1").ToLowerInvariant(); + } + // Use camelCase + return char.ToLowerInvariant(part[0]) + part[1..]; + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/TestServerBase.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/TestServerBase.cs new file mode 100644 index 000000000000..24fe89c282f7 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/TestServerBase.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core.TestFramework; +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace TestProjects.Spector.Tests +{ + public class TestServerBase : IDisposable + { + private static Lazy _buildProperties = new(() => (BuildPropertiesAttribute)typeof(TestServerBase).Assembly.GetCustomAttributes(typeof(BuildPropertiesAttribute), false)[0]); + + private readonly Process? _process; + public HttpClient Client { get; } + public Uri Host { get; } + public string Port { get; } + + public TestServerBase(string processPath, string processArguments) + { + var portPhrase = "Started server on "; + + var processStartInfo = new ProcessStartInfo("node", $"{processPath} {processArguments}") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + _process = Process.Start(processStartInfo); + if (_process == null) + { + throw new InvalidOperationException($"Unable to start process {processStartInfo.FileName} {processStartInfo.Arguments}"); + } + ProcessTracker.Add(_process); + Debug.Assert(_process != null); + while (!_process.HasExited) + { + var s = _process.StandardOutput.ReadLine(); + var indexOfPort = s?.IndexOf(portPhrase); + if (indexOfPort > 0) + { + Port = s!.Substring(indexOfPort.Value + portPhrase.Length).Trim(); + Host = new Uri($"http://localhost:{Port}"); + Client = new HttpClient + { + BaseAddress = Host + }; + _ = Task.Run(ReadOutput); + return; + } + } + + if (Client == null || Host == null || Port == null) + { + throw new InvalidOperationException($"Unable to detect server port {_process.StandardOutput.ReadToEnd()} {_process.StandardError.ReadToEnd()}"); + } + } + + protected static string GetCoverageDirectory() + { + return Path.Combine(_buildProperties.Value.ArtifactsDirectory, "coverage"); + } + + protected static string GetRepoRootDirectory() + { + return _buildProperties.Value.RepoRoot; + } + + protected static string GetNodeModulesDirectory() + { + var repoRoot = _buildProperties.Value.RepoRoot; + var nodeModulesDirectory = Path.Combine(repoRoot, "eng", "packages", "http-client-csharp-mgmt", "node_modules"); + if (Directory.Exists(nodeModulesDirectory)) + { + return nodeModulesDirectory; + } + + throw new InvalidOperationException($"Cannot find 'node_modules' in parent directories of {typeof(SpectorServer).Assembly.Location}."); + } + + private void ReadOutput() + { + while (_process is not null && !_process.HasExited && !_process.StandardOutput.EndOfStream) + { + _process.StandardOutput.ReadToEnd(); + _process.StandardError.ReadToEnd(); + } + } + + protected virtual void Stop(Process process) + { + process.Kill(true); + } + + public void Dispose() + { + if (_process is not null) + Stop(_process); + + _process?.Dispose(); + Client?.Dispose(); + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/TestServerSessionBase.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/TestServerSessionBase.cs new file mode 100644 index 000000000000..a2d40991ed54 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/TestServerSessionBase.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; + +namespace TestProjects.Spector.Tests +{ + public abstract class TestServerSessionBase : IAsyncDisposable where T : TestServerBase + { + private static readonly object _serverCacheLock = new object(); + private static T? s_serverCache; + + public T? Server { get; private set; } + public Uri Host => Server?.Host ?? throw new InvalidOperationException("Server is not instantiated"); + + protected TestServerSessionBase() + { + Server = GetServer(); + } + + private ref T? GetServerCache() + { + return ref s_serverCache; + } + + private T CreateServer() + { + var server = Activator.CreateInstance(typeof(T)); + if (server is null) + { + throw new InvalidOperationException($"Unable to construct a new instance of {typeof(T).Name}"); + } + + return (T)server; + } + + private T GetServer() + { + T? server; + lock (_serverCacheLock) + { + ref var cache = ref GetServerCache(); + server = cache; + cache = null; + } + + if (server == null) + { + server = CreateServer(); + } + + return server; + } + + public abstract ValueTask DisposeAsync(); + + protected void Return() + { + bool disposeServer = true; + lock (_serverCacheLock) + { + ref var cache = ref GetServerCache(); + if (cache == null) + { + cache = Server; + Server = null; + disposeServer = false; + } + } + + if (disposeServer) + { + Server?.Dispose(); + } + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/SpectorTestBase.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/SpectorTestBase.cs new file mode 100644 index 000000000000..00d169e0fa6f --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/SpectorTestBase.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace TestProjects.Spector.Tests +{ + public abstract class SpectorTestBase + { + public async Task Test(Func test) + { + var server = SpectorServerSession.Start(); + + try + { + await test(server.Host); + } + catch (Exception ex) + { + try + { + await server.DisposeAsync(); + } + catch (Exception disposeException) + { + throw new AggregateException(ex, disposeException); + } + + throw; + } + + await server.DisposeAsync(); + } + + internal static async Task InvokeMethodAsync(object obj, string methodName, params object[] args) + { + Task? task = (Task?)InvokeMethod(obj, methodName, args); + if (task != null) + { + await task; + return GetProperty(task, "Result"); + } + return null; + } + + internal static object? GetProperty(object obj, string propertyName) + { + return obj.GetType().GetProperty(propertyName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)!.GetValue(obj); + } + + internal static object? InvokeMethod(object obj, string methodName, params object[] args) + => InvokeMethodInternal(obj.GetType(), obj, methodName, [], + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public, args); + + + private static object? InvokeMethodInternal(Type type, + object obj, + string methodName, + IEnumerable genericArgs, + BindingFlags flags, + params object[] args) + { + var methods = type.GetMethods(flags); + MethodInfo? methodInfo = null; + foreach (var method in methods) + { + var methodToTry = method; + if (genericArgs.Any()) + { + methodToTry = methodToTry.MakeGenericMethod([.. genericArgs]); + } + + if (!methodToTry.Name.Equals(methodName, StringComparison.Ordinal)) + continue; + + var parameters = methodToTry.GetParameters(); + if (parameters.Length < args.Length) + continue; + + //verify the types match for all the args passed in + int i = 0; + bool isMatch = true; + foreach (var parameter in parameters.Take(args.Length)) + { + if (!parameter.ParameterType.IsAssignableFrom(args[i++]?.GetType()) && + !CanAssignNull(parameter.ParameterType, args[i - 1])) + { + isMatch = false; + break; + } + } + + if (isMatch) + { + methodInfo = methodToTry; + break; + } + } + + if (methodInfo == null) + throw new MissingMethodException( + $"No matching method found for type {type} with the provided name {methodName}."); + + return methodInfo.Invoke(obj, + [.. args, .. methodInfo.GetParameters().Skip(args.Length).Select(p => p.DefaultValue)]); + } + + private static bool CanAssignNull(Type parameterType, object arg) + { + if (arg is not null) + return false; + + return !parameterType.IsValueType || + (parameterType.IsGenericType && parameterType.GetGenericTypeDefinition().Equals(typeof(Nullable<>))); + } + } +} diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/TestProjects.Spector.Tests.csproj b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/TestProjects.Spector.Tests.csproj new file mode 100644 index 000000000000..324ee72c3024 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/TestProjects.Spector.Tests.csproj @@ -0,0 +1,50 @@ + + + + net9.0 + net9.0 + + + + + + + + + + + + + + + + + + + + <_Parameter1>$(RepoRoot) + <_Parameter2>$(RepoRoot)\eng\packages\http-client-csharp-mgmt\generator\artifacts + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector/Directory.Build.props b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector/Directory.Build.props new file mode 100644 index 000000000000..db1ad39457f0 --- /dev/null +++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector/Directory.Build.props @@ -0,0 +1,10 @@ + + + true + false + + + + diff --git a/eng/packages/http-client-csharp-mgmt/package-lock.json b/eng/packages/http-client-csharp-mgmt/package-lock.json index 724fa16d0548..9bd745803474 100644 --- a/eng/packages/http-client-csharp-mgmt/package-lock.json +++ b/eng/packages/http-client-csharp-mgmt/package-lock.json @@ -27,6 +27,8 @@ "@typespec/http-specs": "0.1.0-alpha.28", "@typespec/openapi": "1.6.0", "@typespec/rest": "0.76.0", + "@typespec/spec-api": "0.1.0-alpha.10", + "@typespec/spector": "0.1.0-alpha.20", "@typespec/streams": "0.76.0", "@typespec/tspd": "0.73.1", "@typespec/versioning": "0.76.0", diff --git a/eng/packages/http-client-csharp-mgmt/package.json b/eng/packages/http-client-csharp-mgmt/package.json index 2de26e7c92a0..c349cffa75d2 100644 --- a/eng/packages/http-client-csharp-mgmt/package.json +++ b/eng/packages/http-client-csharp-mgmt/package.json @@ -56,6 +56,8 @@ "@typespec/openapi": "1.6.0", "@typespec/rest": "0.76.0", "@typespec/streams": "0.76.0", + "@typespec/spec-api": "0.1.0-alpha.10", + "@typespec/spector": "0.1.0-alpha.20", "@typespec/tspd": "0.73.1", "@typespec/versioning": "0.76.0", "@typespec/xml": "0.76.0",