File: GoDocCommentTests.cs
Web Access
Project: src\tests\Aspire.Hosting.CodeGeneration.Go.Tests\Aspire.Hosting.CodeGeneration.Go.Tests.csproj (Aspire.Hosting.CodeGeneration.Go.Tests)
// 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;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes;
using Aspire.Hosting.RemoteHost;
using Aspire.Shared.CodeGeneration;
using Aspire.TestUtilities;
using Aspire.TypeSystem;
 
namespace Aspire.Hosting.CodeGeneration.Go.Tests;
 
public class GoDocCommentTests
{
    private const string ResourceTypeId = "Tests/Tests.DocumentedResource";
    private const string MarkerTypeId = "Tests/Tests.IDocumentedMarker";
    private const string SettingsTypeId = "Tests/Tests.DocumentedSettings";
    private const string StatusTypeId = "Tests/Tests.DocumentedStatus";
 
    // The documentation grammar the scanner produces: one line per summary, ats-ref placeholders, generic arity
    // markers, code fences and "- " list items. See AtsCapabilityScanner.
    private const string RichRemarks =
        "Usage\n```\nresource.WithDocumentation(\"docs\")\n```\nThe `name` and `settings` arguments are required, so pass `null` for neither. " +
        "See {@ats-ref type:Tests.DocumentedSettings|DocumentedSettings}, `IResourceBuilder`1` and " +
        "{@ats-ref method:DocumentedResource.withDocumentation|WithDocumentation}.\n- name: Must be unique.\n- port: Defaults to 8080.";
 
    public enum DocumentedStatus
    {
        Pending,
        Running,
        Retired,
        Unknown
    }
 
    [Fact]
    public async Task GenerateDistributedApplication_WithDocumentedMetadata_EmitsGodoc()
    {
        var files = new AtsGoCodeGenerator().GenerateDistributedApplication(CreateDocumentedContext());
 
        await Verify(DeclarationsOnly(files["aspire.go"]), extension: "go")
            .UseFileName("DocumentedMetadataGeneratedAspire");
    }
 
    [Fact]
    public async Task Render_FormatsGodocBlocks()
    {
        var docs = CreateDocComment();
        var cases = new (string Label, GoDocContent Content)[]
        {
            ("method summary is reworded to start with the name", new GoDocContent { GoName = "AddRedis", Summary = "Adds a Redis container resource." }),
            ("summary that already starts with the name is kept", new GoDocContent { GoName = "AddRedis", Summary = "AddRedis adds a Redis container resource." }),
            ("getter", new GoDocContent { GoName = "DisplayName", Subject = GoDocSubject.Getter, Summary = "Gets or sets the display name." }),
            ("setter", new GoDocContent { GoName = "SetDisplayName", Subject = GoDocSubject.Setter, Summary = "Gets or sets the display name." }),
            ("boolean getter", new GoDocContent { GoName = "IsEnabled", Subject = GoDocSubject.Getter, Summary = "Gets or sets a value indicating whether the resource is enabled." }),
            ("setter falls back to the description when the property text cannot be reworded", new GoDocContent { GoName = "SetPort", Subject = GoDocSubject.Setter, Summary = "The port.", FallbackSummary = "Sets the Port property" }),
            ("field with a noun phrase", new GoDocContent { GoName = "Port", Subject = GoDocSubject.Field, Summary = "The port to listen on." }),
            ("field with a sentence", new GoDocContent { GoName = "Pending", Subject = GoDocSubject.Field, Summary = "The resource is pending." }),
            ("field with whether", new GoDocContent { GoName = "UseTls", Subject = GoDocSubject.Field, Summary = "Whether TLS is used." }),
            ("type without a verb", new GoDocContent { GoName = "Settings", Subject = GoDocSubject.Type, Summary = "Test settings." }),
            ("fallback summary", new GoDocContent { GoName = "Settings", Subject = GoDocSubject.Type, FallbackSummary = "Settings represents Settings." }),
            ("nothing to document", new GoDocContent { GoName = "Settings", Subject = GoDocSubject.Type }),
            ("remarks, notes, parameters and returns", new GoDocContent
            {
                GoName = "WithDocumentation",
                Summary = "Adds documentation.",
                Remarks = RichRemarks,
                Notes = ["Only one of the values may be set."],
                Parameters = [new GoDocParameter("name", "name", "The name."), new GoDocParameter("settings", "settings", null), new GoDocParameter("port", "options", "Optional parameters; see [WithDocumentationOptions].")],
                Returns = "The builder."
            }),
            ("experimental", new GoDocContent { GoName = "WithPreview", Summary = "Adds a preview.", IsExperimental = true }),
            ("obsolete with a message", new GoDocContent { GoName = "WithLegacy", Summary = "Adds the legacy behavior.", IsObsolete = true, ObsoleteMessage = "Use WithPreview instead.\nIt replaces this method." }),
            ("obsolete without a message", new GoDocContent { GoName = "WithLegacy", Summary = "Adds the legacy behavior.", IsObsolete = true }),
            ("experimental and obsolete", new GoDocContent { GoName = "WithLegacy", Summary = "Adds the legacy behavior.", IsExperimental = true, IsObsolete = true, ObsoleteMessage = "Use WithPreview." }),
            ("long text wraps without splitting words or starting a list", new GoDocContent
            {
                GoName = "WithLongText",
                Summary = "Adds a resource whose documentation is long enough that it has to wrap across several comment lines - the next dash must not start a list item - or a heading # either.",
                Parameters = [new GoDocParameter("value", "value", "A parameter whose description is also long enough that it has to wrap onto a continuation line of the list.")]
            }),
            ("a remark that looks like a heading stays a paragraph", new GoDocContent { GoName = "WithHeading", Summary = "Adds a heading.", Remarks = "Overview\n- first item" }),
            ("special characters are kept", new GoDocContent { GoName = "WithSpecialCharacters", Summary = "Adds <special> characters & \"quotes\", a backslash \\ and a */ terminator. Unicode: 你好こんにちは." }),
        };
 
        var text = string.Join("\n\n", cases.Select(c => $"## {c.Label}\n{string.Join('\n', docs.Render(c.Content))}"));
 
        await Verify(text, extension: "txt").UseFileName("GoDocCommentRender");
    }
 
    [Theory]
    [InlineData("Adds Foo.\nfunc init() {}\n// ")]
    [InlineData("Adds Foo.\r\nfunc init() {}\r\n")]
    [InlineData("Adds Foo.\rfunc init() {}")]
    [InlineData("Adds Foo.\u2028func init() {}\u2029")]
    [InlineData("Adds Foo.\0func init() {}\uFEFF")]
    public void Render_KeepsEveryLineInsideTheComment(string text)
    {
        var lines = CreateDocComment().Render(new GoDocContent
        {
            GoName = "AddFoo",
            Summary = text,
            FallbackSummary = text,
            Remarks = text,
            Notes = [text],
            Parameters = [new GoDocParameter("foo", "foo", text)],
            Returns = text,
            IsObsolete = true,
            ObsoleteMessage = text
        });
 
        Assert.NotEmpty(lines);
        Assert.All(lines, line => Assert.Matches(@"^//(?:[ \t][^\r\n\u0000\u2028\u2029\uFEFF]*)?\z", line));
    }
 
    // A period followed by a non-space and a possessive 's are both allowed in a heading, so gofmt turns these
    // one-line paragraphs into "# Heading" unless they end with a period.
    [Theory]
    [InlineData("Requires Redis 7.2")]
    [InlineData("Used with the resource's name")]
    public void Render_EndsHeadingLookingRemarksWithAPeriod(string remarks)
    {
        var lines = CreateDocComment().Render(new GoDocContent
        {
            GoName = "WithThing",
            Summary = "Adds a thing.",
            Remarks = remarks,
            Returns = "The builder."
        });
 
        Assert.Equal(
            [
                "// WithThing adds a thing.",
                "//",
                $"// {remarks}.",
                "//",
                "// Returns: The builder."
            ],
            lines);
    }
 
    [Fact]
    public void GeneratedSdk_DocCommentsFollowGodocConventions()
    {
        var context = CreateRealSdkContext();
        var sdk = new AtsGoCodeGenerator().GenerateDistributedApplication(context)["aspire.go"];
 
        var violations = new List<string>();
        var commentedDeclarations = 0;
 
        // Generated files use the platform line ending, so a lone CR would be a real defect rather than a CRLF pair.
        if (Regex.IsMatch(sdk, "\r(?!\n)"))
        {
            violations.Add("The SDK contains a carriage return that does not belong to a CRLF pair.");
        }
 
        var lines = sdk.ReplaceLineEndings("\n").Split('\n');
        for (var i = 0; i < lines.Length; i++)
        {
            var line = lines[i];
            if (!IsComment(line))
            {
                if (GetDeclaredName(line) is { } name && i > 0 && IsComment(lines[i - 1]))
                {
                    var first = i - 1;
                    while (first > 0 && IsComment(lines[first - 1]))
                    {
                        first--;
                    }
 
                    commentedDeclarations++;
                    // go doc, gopls hover and staticcheck all expect the comment to start with the identifier.
                    if (!Regex.IsMatch(lines[first].Trim(), $"^// {Regex.Escape(name)}(?:[ ,.:]|$)"))
                    {
                        violations.Add($"{first + 1}: the comment for '{name}' must start with its name: {lines[first].Trim()}");
                    }
                }
 
                continue;
            }
 
            if (line.EndsWith(' ') || line.EndsWith('\t'))
            {
                violations.Add($"{i + 1}: trailing whitespace: {line}");
            }
 
            // gopls and staticcheck only recognize Deprecated: at the start of a paragraph.
            if (line.TrimStart().StartsWith("// Deprecated:", StringComparison.Ordinal) && i > 0 && IsComment(lines[i - 1]) && lines[i - 1].Trim() != "//")
            {
                violations.Add($"{i + 1}: Deprecated: must start its own paragraph: {line}");
            }
        }
 
        // Metadata must reach the output: if the scanner reports an obsolete or experimental capability, the SDK says so.
        if (context.Capabilities.Any(c => c.IsObsolete))
        {
            Assert.Contains("// Deprecated: ", sdk);
        }
 
        if (context.Capabilities.Any(AtsTypeSystemCompatibility.IsExperimental))
        {
            Assert.Contains("// # Experimental", sdk);
        }
 
        // Every documented enum value is written as a commented constant, and no other constant is commented.
        var documentedValues = context.EnumTypes.SelectMany(e => e.ValueInfos).Count(v => !string.IsNullOrWhiteSpace(v.Documentation?.Summary));
        var commentedConstants = lines.Where((line, i) => i > 0 && IsComment(lines[i - 1]) && Regex.IsMatch(line, "^\t\\w+ \\w+ = \"[^\"]*\"$")).Count();
        Assert.Equal(documentedValues, commentedConstants);
 
        Assert.NotEqual(0, commentedDeclarations);
        Assert.Empty(violations);
 
        static bool IsComment(string line) => line.TrimStart().StartsWith("//", StringComparison.Ordinal);
    }
 
    [Fact]
    [RequiresTools(["go", "gofmt"])]
    public async Task GeneratedSdk_PassesGoToolchainChecks()
    {
        var directory = Directory.CreateTempSubdirectory();
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5));
        try
        {
            var files = new AtsGoCodeGenerator().GenerateDistributedApplication(CreateRealSdkContext());
            foreach (var (name, content) in files)
            {
                await File.WriteAllTextAsync(Path.Combine(directory.FullName, name), content, timeout.Token);
            }
 
            var build = await RunAsync("go", directory.FullName, timeout.Token, "build", "./...");
            Assert.True(build.ExitCode == 0, $"go build failed:{Environment.NewLine}{build.Output}");
 
            var vet = await RunAsync("go", directory.FullName, timeout.Token, "vet", "./...");
            Assert.True(vet.ExitCode == 0, $"go vet failed:{Environment.NewLine}{vet.Output}");
 
            // gofmt rewrites a doc comment that is not in canonical form, so an unchanged comment line proves the
            // generated comment already is. go/printer only reformats doc comments that start in column 1, so this
            // covers top-level declarations. Tab-indented interface-method and struct-field comments are never
            // reformatted; Render_EndsHeadingLookingRemarksWithAPeriod covers the heading rule they depend on.
            // The code itself is not gofmt-formatted, only the comments are checked.
            var formatted = await RunAsync("gofmt", directory.FullName, timeout.Token, "aspire.go");
            Assert.True(formatted.ExitCode == 0, $"gofmt failed:{Environment.NewLine}{formatted.Output}");
 
            var generated = CommentLines(files["aspire.go"]);
            var reformatted = CommentLines(formatted.Output);
            for (var i = 0; i < Math.Min(generated.Count, reformatted.Count); i++)
            {
                Assert.True(generated[i] == reformatted[i], $"gofmt rewrote comment line {i + 1}:{Environment.NewLine}  generated: {generated[i]}{Environment.NewLine}  gofmt:     {reformatted[i]}");
            }
 
            Assert.Equal(generated.Count, reformatted.Count);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    private static GoDocComment CreateDocComment()
    {
        var types = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["DocumentedSettings"] = "DocumentedSettings",
            ["WithDocumentationOptions"] = "WithDocumentationOptions"
        };
 
        return new GoDocComment(name => types.GetValueOrDefault(name), name => char.ToUpperInvariant(name[0]) + name[1..]);
    }
 
    private static AtsContext CreateRealSdkContext()
    {
        return AtsCapabilityScanner.ScanAssemblies([typeof(DistributedApplication).Assembly, typeof(TestRedisResource).Assembly]).ToAtsContext();
    }
 
    private static string? GetDeclaredName(string line)
    {
        // Top-level declarations (type, var, func and methods) and tab-indented members (interface methods, struct
        // fields and constants). Only exported names are documented.
        var match = Regex.Match(line, @"^(?:type |var |const |func (?:\([^)]*\) )?|\t)(?<name>[A-Z]\w*)\b");
        return match.Success ? match.Groups["name"].Value : null;
    }
 
    private static List<string> CommentLines(string source)
    {
        return source.ReplaceLineEndings("\n").Split('\n').Select(line => line.Trim()).Where(line => line.StartsWith("//", StringComparison.Ordinal)).ToList();
    }
 
    internal static async Task<(int ExitCode, string Output)> RunAsync(string fileName, string workingDirectory, CancellationToken cancellationToken, params string[] arguments)
    {
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo(fileName)
        {
            WorkingDirectory = workingDirectory,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            StandardOutputEncoding = Encoding.UTF8,
            StandardErrorEncoding = Encoding.UTF8,
            UseShellExecute = false,
            CreateNoWindow = true
        };
        foreach (var argument in arguments)
        {
            process.StartInfo.ArgumentList.Add(argument);
        }
 
        process.Start();
        var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken);
        var stderr = process.StandardError.ReadToEndAsync(cancellationToken);
        try
        {
            await process.WaitForExitAsync(cancellationToken);
        }
        catch (OperationCanceledException)
        {
            process.Kill(entireProcessTree: true);
            throw;
        }
 
        // gofmt prints the formatted source to stdout and diagnostics to stderr; go prints diagnostics to stderr.
        return (process.ExitCode, await stdout + await stderr);
    }
 
    // The declarations are what go doc and gopls show, so the snapshot keeps the comments and signatures and drops
    // function bodies, which belong to the capability-emission tests.
    private static string DeclarationsOnly(string sdk)
    {
        var lines = sdk.ReplaceLineEndings("\n").Split('\n');
        var start = Array.IndexOf(lines, "// Enums") - 1;
        var end = Array.IndexOf(lines, "// Per-client handle wrapper registration") - 1;
 
        var result = new List<string>();
        var inBody = false;
        foreach (var line in lines[start..end])
        {
            if (inBody)
            {
                inBody = line != "}";
                continue;
            }
 
            if (line.StartsWith("func ", StringComparison.Ordinal) && line.EndsWith('{'))
            {
                result.Add($"{line} ... }}");
                inBody = true;
                continue;
            }
 
            result.Add(line);
        }
 
        return string.Join('\n', result);
    }
 
    private static AtsContext CreateDocumentedContext()
    {
        var stringType = Primitive(AtsConstants.String);
        var boolType = Primitive(AtsConstants.Boolean);
        var voidType = Primitive(AtsConstants.Void);
        var resourceType = new AtsTypeRef { TypeId = ResourceTypeId, Category = AtsTypeCategory.Handle };
        var settingsType = new AtsTypeRef { TypeId = SettingsTypeId, Category = AtsTypeCategory.Dto };
        var statusType = new AtsTypeRef { TypeId = StatusTypeId, Category = AtsTypeCategory.Enum };
        var tagsType = new AtsTypeRef { TypeId = "Tests/List<string>", Category = AtsTypeCategory.List, ElementType = stringType };
 
        return new AtsContext
        {
            Capabilities =
            [
                Capability("withDocumentation", resourceType, new AtsDocumentationInfo { Summary = "Adds documentation to the resource.", Remarks = RichRemarks, Returns = "The resource builder." },
                [
                    Parameter("name", stringType, "The name of the documentation."),
                    Parameter("settings", settingsType, "The settings to apply, or `null` to keep the defaults."),
                    Parameter("port", Primitive(AtsConstants.Number), "The port to use. Pass `name` to override.", isOptional: true),
                    Parameter("status", statusType, "The initial status.", isOptional: true),
                    Parameter("enabled", boolType, isOptional: true)
                ]),
                Capability("withObsoleteDocumentation", resourceType, new AtsDocumentationInfo { Summary = "Adds documentation using the legacy convention." },
                    [Parameter("name", stringType)], isObsolete: true, obsoleteMessage: "Use WithDocumentation instead."),
                Capability("withLegacyDocumentation", resourceType, description: "Adds the legacy documentation.", isObsolete: true),
                Capability("withPreviewDocumentation", resourceType, new AtsDocumentationInfo { Summary = "Adds preview documentation." }, isExperimental: true),
                Capability("withPreviewStatus", resourceType, new AtsDocumentationInfo { Summary = "Sets the preview status." },
                    [Parameter("status", statusType, "The status to set.")], isExperimental: true, isObsolete: true, obsoleteMessage: "Use WithDocumentation instead."),
                Capability("withDirectOptions", resourceType, new AtsDocumentationInfo { Summary = "Applies the settings." },
                    [Parameter("options", settingsType, "The settings to apply.", isOptional: true)]),
                Capability("withHeading", resourceType, new AtsDocumentationInfo { Summary = "Adds a heading.", Remarks = "Overview\n- first item" }),
                Capability("withSpecialText", resourceType, new AtsDocumentationInfo
                {
                    Summary = "Handles <special> characters & line endings.\r\nThe second line follows a CRLF.\rThe third line follows a CR.",
                    Remarks = "A remark that is long enough to be wrapped across several comment lines without splitting a word, and that keeps going for a while so the wrapping is visible."
                }),
                Capability("displayName", stringType, new AtsDocumentationInfo { Summary = "Gets or sets the display name." },
                    kind: AtsCapabilityKind.PropertyGetter, description: "Gets the DisplayName property"),
                Capability("setDisplayName", resourceType, new AtsDocumentationInfo { Summary = "Gets or sets the display name." },
                    [Parameter("value", stringType, "Gets or sets the display name.")], AtsCapabilityKind.PropertySetter, "Sets the DisplayName property"),
                Capability("isEnabled", boolType, new AtsDocumentationInfo { Summary = "Gets or sets a value indicating whether the resource is enabled." },
                    kind: AtsCapabilityKind.PropertyGetter, description: "Gets the IsEnabled property"),
                Capability("port", Primitive(AtsConstants.Number), new AtsDocumentationInfo { Summary = "The port of the resource." },
                    kind: AtsCapabilityKind.PropertyGetter, description: "Gets the Port property"),
                Capability("tags", tagsType, new AtsDocumentationInfo { Summary = "Gets the tags of the resource." }, kind: AtsCapabilityKind.PropertyGetter),
                Capability("getStatus", stringType, new AtsDocumentationInfo { Summary = "Gets the status of the resource.", Returns = "The status of the resource." }, kind: AtsCapabilityKind.InstanceMethod),
                Capability("reset", voidType, new AtsDocumentationInfo { Summary = "Resets the resource to its defaults." },
                    [Parameter("force", boolType, "Whether to discard pending changes.")], AtsCapabilityKind.InstanceMethod),
                Capability("undocumented", voidType, kind: AtsCapabilityKind.InstanceMethod)
            ],
            HandleTypes =
            [
                new AtsTypeInfo
                {
                    AtsTypeId = ResourceTypeId,
                    Documentation = new AtsDocumentationInfo { Summary = "Represents a documented resource.", Remarks = "Create one with the builder." }
                },
                new AtsTypeInfo
                {
                    AtsTypeId = MarkerTypeId,
                    IsInterface = true,
                    Documentation = new AtsDocumentationInfo { Summary = "Marks resources that carry documentation." }
                }
            ],
            DtoTypes =
            [
                new AtsDtoTypeInfo
                {
                    TypeId = SettingsTypeId,
                    Name = "DocumentedSettings",
                    Documentation = new AtsDocumentationInfo { Summary = "Represents the settings of a documented resource.", Remarks = "Settings are applied when the resource starts." },
                    Properties =
                    [
                        new AtsDtoPropertyInfo { Name = "DisplayName", Type = stringType, Documentation = new AtsDocumentationInfo { Summary = "Gets or sets the display name." } },
                        new AtsDtoPropertyInfo { Name = "Enabled", Type = boolType, Documentation = new AtsDocumentationInfo { Summary = "Gets or sets a value indicating whether the resource is enabled." } },
                        new AtsDtoPropertyInfo { Name = "Port", Type = Primitive(AtsConstants.Number), Description = "The port of the resource." },
                        new AtsDtoPropertyInfo { Name = "Undocumented", Type = stringType }
                    ]
                }
            ],
            EnumTypes =
            [
                new AtsEnumTypeInfo
                {
                    TypeId = StatusTypeId,
                    Name = "DocumentedStatus",
                    ClrType = typeof(DocumentedStatus),
                    Values = [nameof(DocumentedStatus.Pending), nameof(DocumentedStatus.Running), nameof(DocumentedStatus.Retired), nameof(DocumentedStatus.Unknown)],
                    Documentation = new AtsDocumentationInfo { Summary = "Describes the lifecycle state of a documented resource." },
                    ValueInfos =
                    [
                        new AtsEnumValueInfo { Name = nameof(DocumentedStatus.Pending), Documentation = new AtsDocumentationInfo { Summary = "The resource is pending." } },
                        new AtsEnumValueInfo { Name = nameof(DocumentedStatus.Running), Documentation = new AtsDocumentationInfo { Summary = "Indicates that the resource is running.", Remarks = "It accepts requests." } },
                        new AtsEnumValueInfo { Name = nameof(DocumentedStatus.Retired), Documentation = new AtsDocumentationInfo { Summary = "A resource that was retired." } }
                    ]
                }
            ],
            ExportedValues =
            [
                new AtsExportedValueInfo
                {
                    OwningAssemblyName = "Tests",
                    PathSegments = ["DocumentedDefaults", "Primary"],
                    Type = settingsType,
                    Value = JsonNode.Parse("""{"DisplayName":"primary","Enabled":true,"Port":8080}"""),
                    Documentation = new AtsDocumentationInfo { Summary = "The primary settings." }
                },
                new AtsExportedValueInfo
                {
                    OwningAssemblyName = "Tests",
                    PathSegments = ["DocumentedDefaults", "Secondary"],
                    Type = settingsType,
                    Value = JsonNode.Parse("""{"DisplayName":"secondary","Enabled":false,"Port":8081}"""),
                    Description = "The secondary settings."
                }
            ]
        };
    }
 
    private static AtsTypeRef Primitive(string typeId) => new() { TypeId = typeId, Category = AtsTypeCategory.Primitive };
 
    private static AtsParameterInfo Parameter(string name, AtsTypeRef type, string? summary = null, bool isOptional = false) => new()
    {
        Name = name,
        Type = type,
        IsOptional = isOptional,
        Documentation = summary is null ? null : new AtsDocumentationInfo { Summary = summary }
    };
 
    private static AtsCapabilityInfo Capability(
        string name,
        AtsTypeRef returnType,
        AtsDocumentationInfo? documentation = null,
        IReadOnlyList<AtsParameterInfo>? parameters = null,
        AtsCapabilityKind kind = AtsCapabilityKind.Method,
        string? description = null,
        bool isObsolete = false,
        string? obsoleteMessage = null,
        bool isExperimental = false)
    {
        var resourceType = new AtsTypeRef { TypeId = ResourceTypeId, Category = AtsTypeCategory.Handle };
        return new AtsCapabilityInfo
        {
            CapabilityId = $"Tests/{name}",
            MethodName = name,
            Description = description,
            Documentation = documentation,
            IsObsolete = isObsolete,
            ObsoleteMessage = obsoleteMessage,
            IsExperimental = isExperimental,
            Parameters = [Parameter("builder", resourceType), .. parameters ?? []],
            ReturnType = returnType,
            TargetTypeId = ResourceTypeId,
            TargetType = resourceType,
            TargetParameterName = "builder",
            ExpandedTargetTypes = [resourceType],
            ReturnsBuilder = returnType.TypeId == ResourceTypeId,
            CapabilityKind = kind
        };
    }
}