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..dc8c244e6a21 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 AzureStubGenerator")
+ $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/Spector-Helper.psm1 b/eng/packages/http-client-csharp-mgmt/eng/scripts/Spector-Helper.psm1
new file mode 100644
index 000000000000..deb579f500db
--- /dev/null
+++ b/eng/packages/http-client-csharp-mgmt/eng/scripts/Spector-Helper.psm1
@@ -0,0 +1,115 @@
+$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 = @(
+ # method-subscription-id: Skipped due to "Some file paths are too long" error in CI
+ "http/azure/resource-manager/method-subscription-id"
+)
+
+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..adf43b5c2390
--- /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' 'Azure.Generator.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/Azure.Generator.Management/src/Properties/launchSettings.json b/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Properties/launchSettings.json
index 3fbdd62340df..cd5240b99bd7 100644
--- a/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Properties/launchSettings.json
+++ b/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Properties/launchSettings.json
@@ -1,5 +1,30 @@
{
"profiles": {
+ "http-azure-resource-manager-common-properties": {
+ "commandLineArgs": "$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll $(SolutionDir)/TestProjects/Spector/http/azure/resource-manager/common-properties -g AzureStubGenerator",
+ "commandName": "Executable",
+ "executablePath": "dotnet"
+ },
+ "http-azure-resource-manager-large-header": {
+ "commandLineArgs": "$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll $(SolutionDir)/TestProjects/Spector/http/azure/resource-manager/large-header -g AzureStubGenerator",
+ "commandName": "Executable",
+ "executablePath": "dotnet"
+ },
+ "http-azure-resource-manager-non-resource": {
+ "commandLineArgs": "$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll $(SolutionDir)/TestProjects/Spector/http/azure/resource-manager/non-resource -g AzureStubGenerator",
+ "commandName": "Executable",
+ "executablePath": "dotnet"
+ },
+ "http-azure-resource-manager-operation-templates": {
+ "commandLineArgs": "$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll $(SolutionDir)/TestProjects/Spector/http/azure/resource-manager/operation-templates -g AzureStubGenerator",
+ "commandName": "Executable",
+ "executablePath": "dotnet"
+ },
+ "http-azure-resource-manager-resources": {
+ "commandLineArgs": "$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll $(SolutionDir)/TestProjects/Spector/http/azure/resource-manager/resources -g AzureStubGenerator",
+ "commandName": "Executable",
+ "executablePath": "dotnet"
+ },
"Mgmt-TypeSpec": {
"commandLineArgs": "$(SolutionDir)/../dist/generator/Microsoft.TypeSpec.Generator.dll $(SolutionDir)/TestProjects/Local/Mgmt-TypeSpec -g MgmtClientGenerator",
"commandName": "Executable",
diff --git a/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Azure.Generator.Spector.Tests.csproj b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Azure.Generator.Spector.Tests.csproj
new file mode 100644
index 000000000000..97de6e6bb995
--- /dev/null
+++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Azure.Generator.Spector.Tests.csproj
@@ -0,0 +1,54 @@
+
+
+
+ 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.Tests/Http/Azure/ResourceManager/OperationTemplates/OrderDataTests.cs b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Http/Azure/ResourceManager/OperationTemplates/OrderDataTests.cs
new file mode 100644
index 000000000000..08374bf6bd93
--- /dev/null
+++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Http/Azure/ResourceManager/OperationTemplates/OrderDataTests.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.ClientModel.Primitives;
+using System.Text.Json;
+using Azure;
+using Azure.Core;
+using Azure.ResourceManager.OperationTemplates;
+using Azure.ResourceManager.OperationTemplates.Models;
+using NUnit.Framework;
+using TestProjects.Spector.Tests.Infrastructure;
+
+namespace TestProjects.Spector.Tests.Http.Azure.ResourceManager.OperationTemplates
+{
+ public class OrderDataTests : SpectorModelTests
+ {
+ private static readonly ModelReaderWriterOptions _wireOptions = new ModelReaderWriterOptions("W");
+
+ protected override string JsonPayload => """
+ {
+ "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Azure.ResourceManager.OperationTemplates/orders/order1",
+ "name": "order1",
+ "type": "Azure.ResourceManager.OperationTemplates/orders",
+ "location": "eastus",
+ "tags": {
+ "tagKey1": "tagValue1"
+ },
+ "properties": {
+ "productId": "product1",
+ "amount": 5,
+ "provisioningState": "Succeeded"
+ }
+ }
+ """;
+
+ protected override string WirePayload => JsonPayload;
+
+ protected override OrderData GetModelInstance()
+ {
+ return new OrderData(AzureLocation.EastUS);
+ }
+
+ protected override void VerifyModel(OrderData model, string format)
+ {
+ Assert.AreEqual("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Azure.ResourceManager.OperationTemplates/orders/order1", model.Id.ToString());
+ Assert.AreEqual("order1", model.Name);
+ Assert.AreEqual("eastus", model.Location.Name);
+ Assert.IsNotNull(model.Tags);
+ Assert.AreEqual("tagValue1", model.Tags["tagKey1"]);
+ Assert.IsNotNull(model.Properties);
+ Assert.AreEqual("product1", model.Properties.ProductId);
+ Assert.AreEqual(5, model.Properties.Amount);
+ Assert.AreEqual("Succeeded", model.Properties.ProvisioningState);
+ }
+
+ protected override void CompareModels(OrderData model, OrderData model2, string format)
+ {
+ Assert.AreEqual(model.Id, model2.Id);
+ Assert.AreEqual(model.Name, model2.Name);
+ Assert.AreEqual(model.Location, model2.Location);
+ Assert.AreEqual(model.Properties?.ProductId, model2.Properties?.ProductId);
+ Assert.AreEqual(model.Properties?.Amount, model2.Properties?.Amount);
+ Assert.AreEqual(model.Properties?.ProvisioningState, model2.Properties?.ProvisioningState);
+ }
+
+ protected override OrderData ToModel(Response response)
+ {
+ // Use ModelReaderWriter to deserialize since the FromResponse method is internal
+ return ModelReaderWriter.Read(response.Content, _wireOptions)!;
+ }
+
+ protected override RequestContent ToRequestContent(OrderData model)
+ {
+ // Use ModelReaderWriter to serialize since the ToRequestContent method is internal
+ var binaryData = ModelReaderWriter.Write(model, _wireOptions);
+ return RequestContent.Create(binaryData);
+ }
+ }
+}
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..0c69db248e1f
--- /dev/null
+++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/Infrastructure/SpectorModelTests.cs
@@ -0,0 +1,55 @@
+// 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..f4824d0332a3
--- /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..dbfceca70d52
--- /dev/null
+++ b/eng/packages/http-client-csharp-mgmt/generator/TestProjects/Spector.Tests/SpectorTestBase.cs
@@ -0,0 +1,120 @@
+// 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