From d71953f8a34163bd8b08e74bd918cefa8e158f86 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sun, 14 Jun 2026 13:20:14 -0700 Subject: [PATCH 01/12] Fix custom-modifier type erasure in the WinMD generator type mapping MapTypeSignatureToOutput had no handling for CustomModifierTypeSignature, so any modifier-wrapped type fell through to the 'System.Object' fallback and was silently erased. This affects 'in' parameters on abstract/virtual/interface/delegate members, where the C# compiler emits a 'modreq(InAttribute)' on the by-reference type. For example, a COM interop 'in Guid riid' was emitted as '[in] object' instead of '[in] Guid&'. Strip leading custom modifiers before dispatching on the underlying type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Writers/WinMDWriter.TypeMapping.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs index 07a492aa1..854994016 100644 --- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs +++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs @@ -38,6 +38,14 @@ internal sealed partial class WinMDWriter /// The mapped for use in the output WinMD. private TypeSignature MapTypeSignatureToOutput(TypeSignature inputSignature) { + // Strip custom modifiers ('modreq'/'modopt') before dispatching on the underlying type. + // For example, 'in' parameters on abstract, virtual, interface, or delegate members carry a + // 'modreq(System.Runtime.InteropServices.InAttribute)' on their by-reference type. Custom + // modifiers have no Windows Runtime representation, and without stripping them a modifier-wrapped + // type would fall through to the 'System.Object' fallback at the end of this method, silently + // erasing the real signature. + inputSignature = StripCustomModifiers(inputSignature); + // Handle 'CorLib' types if (inputSignature is CorLibTypeSignature corLib) { @@ -139,6 +147,26 @@ private TypeSignature MapTypeSignatureToOutput(TypeSignature inputSignature) return _outputModule.CorLibTypeFactory.Object; } + /// + /// Strips any leading custom modifiers (modreq/modopt) from a type signature. + /// + /// + /// Custom modifiers carry no Windows Runtime meaning. They are emitted by the C# compiler in a few + /// cases, most notably the modreq(System.Runtime.InteropServices.InAttribute) applied to the + /// by-reference type of an in parameter on abstract, virtual, interface, or delegate members. + /// + /// The type signature to unwrap. + /// The underlying with all leading custom modifiers removed. + private static TypeSignature StripCustomModifiers(TypeSignature signature) + { + while (signature is CustomModifierTypeSignature customModifier) + { + signature = customModifier.BaseType; + } + + return signature; + } + /// /// Imports a type reference from the input module to the output module. /// From 59020dfa64f915491e96dece0475f5fef976f17b Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sun, 14 Jun 2026 13:20:27 -0700 Subject: [PATCH 02/12] Validate unsupported array/span parameter conventions in the WinMD generator Windows Runtime arrays use one of three conventions (ReadOnlySpan for PassArray, Span for FillArray, or 'out T[]' for ReceiveArray), and spans are always passed by value. The generator previously emitted invalid metadata for a few shapes: 'ref T[]'/'in T[]' became '[in] T[]&', and 'out Span'/'out ReadOnlySpan' became a ReceiveArray. Reject these with clear errors (CSWINRTWINMDGEN0011 for by-reference arrays, CSWINRTWINMDGEN0012 for by-reference spans), while preserving the deliberate COM interop 'ref'/'in' scalar behavior (e.g. 'ref Guid riid' -> '[in]') and the by-value array PassArray default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Errors/WellKnownWinMDExceptions.cs | 16 ++++ .../Writers/WinMDWriter.Members.cs | 77 +++++++++++++++++-- .../Writers/WinMDWriter.Types.cs | 2 +- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs index d958c0c7a..01078856c 100644 --- a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs +++ b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs @@ -73,6 +73,22 @@ public static Exception InputAssemblyRuntimeVersionNotFound(string path) return Exception(7, $"Failed to probe the .NET runtime version from the input assembly '{path}'."); } + /// + /// A method has a ref or in array parameter, which is not a valid Windows Runtime array convention. + /// + public static Exception ByReferenceArrayParameterNotSupported(string declaringTypeName, string methodName, string parameterName) + { + return Exception(11, $"Method '{declaringTypeName}.{methodName}' has by-reference array parameter '{parameterName}' passed by 'ref' or 'in'. Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' for a read-only input array (PassArray), 'Span' for a caller-allocated array (FillArray), or 'out T[]' for a callee-allocated array (ReceiveArray)."); + } + + /// + /// A method has a by-reference span parameter (e.g. out Span<T>), which has no Windows Runtime representation. + /// + public static Exception ByReferenceSpanParameterNotSupported(string declaringTypeName, string methodName, string parameterName) + { + return Exception(12, $"Method '{declaringTypeName}.{methodName}' has by-reference span parameter '{parameterName}'. Windows Runtime spans are passed by value: use 'ReadOnlySpan' (PassArray) or 'Span' (FillArray) by value, or 'out T[]' for a callee-allocated array (ReceiveArray)."); + } + /// public static Exception DebugReproDirectoryDoesNotExist(string path) { diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs index 283f6ef41..d44aaf9d4 100644 --- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs +++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs @@ -6,6 +6,7 @@ using AsmResolver.DotNet; using AsmResolver.DotNet.Signatures; using AsmResolver.PE.DotNet.Metadata.Tables; +using WindowsRuntime.WinMDGenerator.Errors; using MethodAttributes = AsmResolver.PE.DotNet.Metadata.Tables.MethodAttributes; using MethodImplAttributes = AsmResolver.PE.DotNet.Metadata.Tables.MethodImplAttributes; using MethodSemanticsAttributes = AsmResolver.PE.DotNet.Metadata.Tables.MethodSemanticsAttributes; @@ -141,6 +142,11 @@ private void AddMethodToClass(TypeDefinition outputType, MethodDefinition inputM /// Any other by-reference type (e.g. ref Guid on a COM interop interface) → [in], matching the MIDL convention for ref const T parameters. /// All other params → [in]. /// + /// + /// Array and span shapes that have no Windows Runtime representation are rejected with a well-known + /// error: a by-reference span (e.g. out Span<T>), or a ref/in array (only + /// out T[] is a valid by-reference array). See . + /// /// private static void AddParameterDefinitions(MethodDefinition outputMethod, MethodDefinition inputMethod) { @@ -154,7 +160,7 @@ private static void AddParameterDefinitions(MethodDefinition outputMethod, Metho if (sigIndex < inputParamTypes.Count) { - paramattributes = GetWinRTParameterAttributes(inputParam, inputParamTypes[sigIndex]); + paramattributes = GetWinRTParameterAttributes(inputMethod, inputParam, inputParamTypes[sigIndex]); } outputMethod.ParameterDefinitions.Add(new ParameterDefinition( @@ -165,15 +171,57 @@ private static void AddParameterDefinitions(MethodDefinition outputMethod, Metho } /// - /// Determines the Windows Runtime parameter attributes based on the input parameter and its type. + /// Determines the Windows Runtime parameter attributes based on the input parameter and its type, + /// validating that the parameter uses a supported Windows Runtime calling convention. /// /// /// If the input parameter already has or /// set, those flags are preserved unchanged. Otherwise, /// the type drives the default per the rules documented on . + /// By-reference spans (e.g. out Span<T>) and ref/in arrays have no + /// Windows Runtime representation and throw a . /// - private static ParameterAttributes GetWinRTParameterAttributes(ParameterDefinition inputParam, TypeSignature inputParamType) + /// The method that declares the parameter (used for error context). + /// The input (provides the In/Out direction flags). + /// The input parameter . + /// The Windows Runtime for the parameter. + /// Thrown if the parameter uses an unsupported array or span convention. + private static ParameterAttributes GetWinRTParameterAttributes( + MethodDefinition inputMethod, + ParameterDefinition inputParam, + TypeSignature inputParamType) { + // Look through any custom modifiers (e.g. the 'modreq(InAttribute)' emitted for 'in' parameters + // on abstract, virtual, interface, or delegate members) to inspect the real underlying signature + TypeSignature parameterType = StripCustomModifiers(inputParamType); + + // Validate by-reference parameters that wrap a span or array. Windows Runtime spans are always + // passed by value, and Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' + // (PassArray), 'Span' (FillArray), or 'out T[]' (ReceiveArray). A by-reference span, or a + // 'ref'/'in' array, has no Windows Runtime representation and is rejected here. + if (parameterType is ByReferenceTypeSignature byReference) + { + TypeSignature elementType = StripCustomModifiers(byReference.BaseType); + + if (IsSpan(elementType) || IsReadOnlySpan(elementType)) + { + throw WellKnownWinMDExceptions.ByReferenceSpanParameterNotSupported( + inputMethod.DeclaringType!.FullName, + inputMethod.Name!.Value, + inputParam.Name!.Value); + } + + // 'out T[]' (ReceiveArray) is valid, but 'ref T[]'/'in T[]' are not. By-reference non-array + // types (e.g. 'ref Guid riid' on a COM interop interface) are still allowed and handled below. + if (elementType is SzArrayTypeSignature && !inputParam.IsOut) + { + throw WellKnownWinMDExceptions.ByReferenceArrayParameterNotSupported( + inputMethod.DeclaringType!.FullName, + inputMethod.Name!.Value, + inputParam.Name!.Value); + } + } + // Preserve any 'In'/'Out' direction flags the input parameter already carries ParameterAttributes inputDirectionFlags = inputParam.Attributes & (ParameterAttributes.In | ParameterAttributes.Out); @@ -183,8 +231,7 @@ private static ParameterAttributes GetWinRTParameterAttributes(ParameterDefiniti } // 'Span' → 'FillArray' pattern: '[out]' without 'BYREF' - if (inputParamType is GenericInstanceTypeSignature genericInstanceSignature && - genericInstanceSignature.GenericType.FullName == "System.Span`1") + if (IsSpan(parameterType)) { return ParameterAttributes.Out; } @@ -195,6 +242,26 @@ private static ParameterAttributes GetWinRTParameterAttributes(ParameterDefiniti return ParameterAttributes.In; } + /// + /// Determines whether a type signature is . + /// + /// The type signature to test. + /// if is ; otherwise, . + private static bool IsSpan(TypeSignature signature) + { + return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.Span`1" }; + } + + /// + /// Determines whether a type signature is . + /// + /// The type signature to test. + /// if is ; otherwise, . + private static bool IsReadOnlySpan(TypeSignature signature) + { + return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.ReadOnlySpan`1" }; + } + /// /// Adds a property definition to a WinMD type (interface or class). /// diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs index 15854de6b..95fed8425 100644 --- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs +++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Types.cs @@ -713,7 +713,7 @@ private void AddExplicitInterfaceImplementations(TypeDefinition inputType, TypeD for (int i = 0; i < paramNames.Length; i++) { ParameterAttributes paramAttr = i < parameterTypes.Length && i < method.ParameterDefinitions.Count - ? GetWinRTParameterAttributes(method.ParameterDefinitions[i], method.Signature!.ParameterTypes[i]) + ? GetWinRTParameterAttributes(method, method.ParameterDefinitions[i], method.Signature!.ParameterTypes[i]) : ParameterAttributes.In; outputMethod.ParameterDefinitions.Add(new ParameterDefinition( From 67039eee7064b770a524d8589ace7a5789b1bdd9 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sun, 14 Jun 2026 13:20:38 -0700 Subject: [PATCH 03/12] Add WinMD generator parameter-convention failure tests Add a WinMDGeneratorTest project that runs the actual cswinrtwinmdgen tool end-to-end: it compiles small C# inputs with Roslyn, invokes the generator as a subprocess, and asserts a non-zero exit code plus the expected CSWINRTWINMDGEN error. The project deliberately focuses on failure cases (unsupported 'ref'/'in' arrays and 'out' spans), which can be exercised here without failing the build; the supported '.winmd' shapes (PassArray/FillArray/ReceiveArray) are covered by the authoring tests. The generator exposes no internals to the test project. Registers the project in the solution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/WinMDGeneratorTestHelper.cs | 138 ++++++++++++++++++ .../Test_ParameterConventions.cs | 94 ++++++++++++ .../WinMDGeneratorTest.csproj | 62 ++++++++ src/cswinrt.slnx | 8 + 4 files changed, 302 insertions(+) create mode 100644 src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs create mode 100644 src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs create mode 100644 src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj diff --git a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs new file mode 100644 index 000000000..671f05eb8 --- /dev/null +++ b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using Basic.Reference.Assemblies; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace WinMDGeneratorTest.Helpers; + +/// +/// Helpers to drive the WinMD generator (cswinrtwinmdgen) end-to-end: compile a small C# input, +/// run the actual tool against it as a separate process, and capture its result. +/// +internal static class WinMDGeneratorTestHelper +{ + /// + /// Compiles the given C# into an assembly and runs the WinMD generator + /// against it, returning the process exit code and combined output. + /// + /// The C# source defining the authored component types. + /// Whether to use the UWP (Windows.UI.Xaml) projections. + /// The generator process exit code and its combined standard output and error. + public static (int ExitCode, string Output) RunGenerator(string source, bool useWindowsUIXamlProjections = false) + { + string toolPath = GetGeneratorPath(); + string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName; + + try + { + string inputAssemblyPath = Path.Combine(temporaryDirectory, "TestInput.dll"); + string outputWinmdPath = Path.Combine(temporaryDirectory, "TestInput.winmd"); + string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp"); + + CompileInputAssembly(source, inputAssemblyPath); + + // Each line of the response file is a single ' ' pair. The input + // assembly is also passed as its own reference path (its directory seeds the resolver). + File.WriteAllLines(responseFilePath, + [ + $"--input-assembly-path {inputAssemblyPath}", + $"--reference-assembly-paths {inputAssemblyPath}", + $"--output-winmd-path {outputWinmdPath}", + "--assembly-version 1.0.0.0", + $"--use-windows-ui-xaml-projections {useWindowsUIXamlProjections}", + ]); + + return RunTool(toolPath, responseFilePath); + } + finally + { + try + { + Directory.Delete(temporaryDirectory, recursive: true); + } + catch (IOException) + { + // Best effort cleanup; a leftover temp directory must not fail the test + } + } + } + + /// + /// Compiles the given C# into an assembly on disk. + /// + private static void CompileInputAssembly(string source, string outputPath) + { + CSharpParseOptions parseOptions = new(LanguageVersion.Preview); + + // The target framework attribute lets the generator probe the .NET runtime version. It is added + // in a separate syntax tree so it does not interfere with any 'using' directives in the test + // source (an assembly attribute must precede type declarations but follow 'using' directives). + SyntaxTree sourceTree = CSharpSyntaxTree.ParseText(source, parseOptions); + SyntaxTree assemblyInfoTree = CSharpSyntaxTree.ParseText( + """[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0")]""", + parseOptions); + + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "TestInput", + syntaxTrees: [sourceTree, assemblyInfoTree], + references: Net100.References.All, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + EmitResult result = compilation.Emit(outputPath); + + Assert.IsTrue(result.Success, $"Input compilation failed:\n{string.Join("\n", result.Diagnostics)}"); + } + + /// + /// Runs dotnet exec <tool> <responseFile> and captures the exit code and output. + /// + private static (int ExitCode, string Output) RunTool(string toolPath, string responseFilePath) + { + ProcessStartInfo startInfo = new("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.ArgumentList.Add("exec"); + startInfo.ArgumentList.Add(toolPath); + startInfo.ArgumentList.Add(responseFilePath); + + using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start the WinMD generator process."); + + string standardOutput = process.StandardOutput.ReadToEnd(); + string standardError = process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + return (process.ExitCode, standardOutput + standardError); + } + + /// + /// Resolves the path to the built cswinrtwinmdgen tool from assembly metadata. + /// + private static string GetGeneratorPath() + { + string? path = typeof(WinMDGeneratorTestHelper).Assembly + .GetCustomAttributes() + .FirstOrDefault(static attribute => attribute.Key == "WinMDGeneratorAssemblyPath")?.Value; + + Assert.IsFalse(string.IsNullOrEmpty(path), "The 'WinMDGeneratorAssemblyPath' assembly metadata was not found."); + + string fullPath = Path.GetFullPath(path!); + + Assert.IsTrue(File.Exists(fullPath), $"The WinMD generator was not found at '{fullPath}'."); + + return fullPath; + } +} diff --git a/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs b/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs new file mode 100644 index 000000000..7398c2781 --- /dev/null +++ b/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using WinMDGeneratorTest.Helpers; + +namespace WinMDGeneratorTest; + +/// +/// End-to-end tests for the WinMD generator's validation of unsupported method parameter conventions. +/// +/// +/// These tests run the actual cswinrtwinmdgen tool and assert that unsupported array/span +/// parameter shapes are rejected with the expected error. Keeping the failure cases here lets us +/// exercise them without breaking the build. The supported conventions that produce a valid +/// .winmd (PassArray/FillArray/ReceiveArray) are covered by the authoring tests instead. +/// +[TestClass] +public class Test_ParameterConventions +{ + [TestMethod] + public void ValidComponent_GeneratesSuccessfully() + { + // Smoke test: a valid component generates a '.winmd' (exit code 0). This validates the + // end-to-end harness so the failure assertions below are meaningful. + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + public interface IComponent + { + int Method(int value); + } + """); + + Assert.AreEqual(0, exitCode, output); + } + + [TestMethod] + public void RefArrayParameter_IsRejected() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + public interface IComponent + { + void Method(ref int[] values); + } + """); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0011"); + } + + [TestMethod] + public void InArrayParameter_IsRejected() + { + // 'in int[]' carries a 'modreq(InAttribute)' on interface members; the generator must still + // see through it to detect the by-reference array. + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + public interface IComponent + { + void Method(in int[] values); + } + """); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0011"); + } + + [TestMethod] + public void OutSpanParameter_IsRejected() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + using System; + public interface IComponent + { + void Method(out Span values); + } + """); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0012"); + } + + [TestMethod] + public void OutReadOnlySpanParameter_IsRejected() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + using System; + public interface IComponent + { + void Method(out ReadOnlySpan values); + } + """); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0012"); + } +} diff --git a/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj new file mode 100644 index 000000000..c70c6a443 --- /dev/null +++ b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj @@ -0,0 +1,62 @@ + + + Exe + net10.0 + x64;x86 + preview + enable + false + + + false + + + + win-x86 + x86 + + + + win-x64 + x64 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/cswinrt.slnx b/src/cswinrt.slnx index 27e04f3c4..d5e936b2e 100644 --- a/src/cswinrt.slnx +++ b/src/cswinrt.slnx @@ -309,6 +309,14 @@ + + + + + + + + From 6ac8a29686bc7569456d07bd46324aa91cdd103a Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sun, 14 Jun 2026 17:19:14 -0700 Subject: [PATCH 04/12] Add WinMD generator invocation and invalid-input failure tests Extend the WinMDGeneratorTest harness with a custom-response-file overload and a public CompileComponent helper, then add Test_InvalidInputs covering the generator's remaining end-to-end failure modes: a missing response file (CSWINRTWINMDGEN0001), a malformed or duplicate-argument response file (0002), a missing required or unparseable argument (0003), a missing or corrupt input assembly (0004), an unwritable output path (0006), and a missing debug-repro directory (0008). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/WinMDGeneratorTestHelper.cs | 99 ++++++--- .../WinMDGeneratorTest/Test_InvalidInputs.cs | 188 ++++++++++++++++++ 2 files changed, 259 insertions(+), 28 deletions(-) create mode 100644 src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs diff --git a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs index 671f05eb8..75dc8dc24 100644 --- a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs +++ b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -21,55 +22,80 @@ internal static class WinMDGeneratorTestHelper { /// /// Compiles the given C# into an assembly and runs the WinMD generator - /// against it, returning the process exit code and combined output. + /// against it with a standard response file, returning the process exit code and combined output. /// /// The C# source defining the authored component types. /// Whether to use the UWP (Windows.UI.Xaml) projections. /// The generator process exit code and its combined standard output and error. public static (int ExitCode, string Output) RunGenerator(string source, bool useWindowsUIXamlProjections = false) { - string toolPath = GetGeneratorPath(); - string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName; - - try + return RunGenerator(temporaryDirectory => { - string inputAssemblyPath = Path.Combine(temporaryDirectory, "TestInput.dll"); - string outputWinmdPath = Path.Combine(temporaryDirectory, "TestInput.winmd"); - string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp"); + string inputAssemblyPath = CompileComponent(source, temporaryDirectory); - CompileInputAssembly(source, inputAssemblyPath); - - // Each line of the response file is a single ' ' pair. The input - // assembly is also passed as its own reference path (its directory seeds the resolver). - File.WriteAllLines(responseFilePath, + return [ $"--input-assembly-path {inputAssemblyPath}", $"--reference-assembly-paths {inputAssemblyPath}", - $"--output-winmd-path {outputWinmdPath}", + $"--output-winmd-path {Path.Combine(temporaryDirectory, "TestInput.winmd")}", "--assembly-version 1.0.0.0", $"--use-windows-ui-xaml-projections {useWindowsUIXamlProjections}", - ]); + ]; + }); + } + + /// + /// Runs the WinMD generator against a caller-provided response file, returning the process exit + /// code and combined output. + /// + /// + /// The receives a fresh temporary working directory and + /// returns the response file lines to run with. It can use to produce + /// an input assembly, or reference any other path under the directory (e.g. to test invalid inputs). + /// + /// Builds the response file lines for a given temporary directory. + /// The generator process exit code and its combined standard output and error. + public static (int ExitCode, string Output) RunGenerator(Func> responseFileFactory) + { + string toolPath = GetGeneratorPath(); + string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName; + + try + { + IReadOnlyList responseFileLines = responseFileFactory(temporaryDirectory); + string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp"); + + File.WriteAllLines(responseFilePath, responseFileLines); return RunTool(toolPath, responseFilePath); } finally { - try - { - Directory.Delete(temporaryDirectory, recursive: true); - } - catch (IOException) - { - // Best effort cleanup; a leftover temp directory must not fail the test - } + TryDeleteDirectory(temporaryDirectory); } } /// - /// Compiles the given C# into an assembly on disk. + /// Runs the WinMD generator pointed at a response file path that does not exist. /// - private static void CompileInputAssembly(string source, string outputPath) + /// The generator process exit code and its combined standard output and error. + public static (int ExitCode, string Output) RunGeneratorWithMissingResponseFile() { + string missingResponseFilePath = Path.Combine(Path.GetTempPath(), $"WinMDGeneratorTest_{Guid.NewGuid():N}.rsp"); + + return RunTool(GetGeneratorPath(), missingResponseFilePath); + } + + /// + /// Compiles the given C# into TestInput.dll in . + /// + /// The C# source defining the authored component types. + /// The directory to emit the assembly into. + /// The full path to the compiled assembly. + public static string CompileComponent(string source, string directory) + { + string outputPath = Path.Combine(directory, "TestInput.dll"); + CSharpParseOptions parseOptions = new(LanguageVersion.Preview); // The target framework attribute lets the generator probe the .NET runtime version. It is added @@ -89,12 +115,14 @@ private static void CompileInputAssembly(string source, string outputPath) EmitResult result = compilation.Emit(outputPath); Assert.IsTrue(result.Success, $"Input compilation failed:\n{string.Join("\n", result.Diagnostics)}"); + + return outputPath; } /// - /// Runs dotnet exec <tool> <responseFile> and captures the exit code and output. + /// Runs dotnet exec <tool> <argument> and captures the exit code and output. /// - private static (int ExitCode, string Output) RunTool(string toolPath, string responseFilePath) + private static (int ExitCode, string Output) RunTool(string toolPath, string argument) { ProcessStartInfo startInfo = new("dotnet") { @@ -106,7 +134,7 @@ private static (int ExitCode, string Output) RunTool(string toolPath, string res startInfo.ArgumentList.Add("exec"); startInfo.ArgumentList.Add(toolPath); - startInfo.ArgumentList.Add(responseFilePath); + startInfo.ArgumentList.Add(argument); using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start the WinMD generator process."); @@ -135,4 +163,19 @@ private static string GetGeneratorPath() return fullPath; } + + /// + /// Deletes a directory recursively, ignoring failures (best effort cleanup). + /// + private static void TryDeleteDirectory(string directory) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + // Best effort cleanup; a leftover temp directory must not fail the test + } + } } diff --git a/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs new file mode 100644 index 000000000..35d9b6349 --- /dev/null +++ b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using WinMDGeneratorTest.Helpers; + +namespace WinMDGeneratorTest; + +/// +/// End-to-end tests for the WinMD generator's handling of invalid invocations: malformed or missing +/// response files, bad arguments, missing or corrupt input assemblies, an unwritable output path, and +/// a missing debug-repro directory. +/// +/// +/// Each test runs the actual cswinrtwinmdgen tool and asserts a non-zero exit code and the +/// expected CSWINRTWINMDGEN error in the output. +/// +[TestClass] +public class Test_InvalidInputs +{ + [TestMethod] + public void MissingResponseFile_IsReported() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGeneratorWithMissingResponseFile(); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0001"); + } + + [TestMethod] + public void MalformedResponseFile_IsReported() + { + // A response file line must be a ' ' pair; a line without a space is invalid + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + [ + "--assembly-version 1.0.0.0", + "this-line-has-no-space", + ]); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0002"); + } + + [TestMethod] + public void DuplicateArgument_IsReported() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + [ + "--assembly-version 1.0.0.0", + "--assembly-version 2.0.0.0", + ]); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0002"); + } + + [TestMethod] + public void MissingRequiredArgument_IsReported() + { + // The required '--output-winmd-path' argument is omitted + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + [ + "--input-assembly-path input.dll", + "--reference-assembly-paths input.dll", + "--assembly-version 1.0.0.0", + "--use-windows-ui-xaml-projections False", + ]); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0003"); + } + + [TestMethod] + public void InvalidBooleanArgument_IsReported() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + [ + "--input-assembly-path input.dll", + "--reference-assembly-paths input.dll", + "--output-winmd-path output.winmd", + "--assembly-version 1.0.0.0", + "--use-windows-ui-xaml-projections not-a-boolean", + ]); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0003"); + } + + [TestMethod] + public void MissingInputAssembly_IsReported() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + { + string missingInput = Path.Combine(temporaryDirectory, "DoesNotExist.dll"); + + return + [ + $"--input-assembly-path {missingInput}", + $"--reference-assembly-paths {missingInput}", + $"--output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}", + "--assembly-version 1.0.0.0", + "--use-windows-ui-xaml-projections False", + ]; + }); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0004"); + } + + [TestMethod] + public void InvalidInputAssembly_IsReported() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + { + string invalidInput = Path.Combine(temporaryDirectory, "NotAPortableExecutable.dll"); + + File.WriteAllText(invalidInput, "This is not a valid PE image."); + + return + [ + $"--input-assembly-path {invalidInput}", + $"--reference-assembly-paths {invalidInput}", + $"--output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}", + "--assembly-version 1.0.0.0", + "--use-windows-ui-xaml-projections False", + ]; + }); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0004"); + } + + [TestMethod] + public void UnwritableOutputPath_IsReported() + { + // The output path points at an existing directory, so the '.winmd' cannot be written + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + { + string inputAssemblyPath = WinMDGeneratorTestHelper.CompileComponent(""" + public interface IComponent + { + int Method(int value); + } + """, temporaryDirectory); + + return + [ + $"--input-assembly-path {inputAssemblyPath}", + $"--reference-assembly-paths {inputAssemblyPath}", + $"--output-winmd-path {temporaryDirectory}", + "--assembly-version 1.0.0.0", + "--use-windows-ui-xaml-projections False", + ]; + }); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0006"); + } + + [TestMethod] + public void MissingDebugReproDirectory_IsReported() + { + (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + { + string inputAssemblyPath = WinMDGeneratorTestHelper.CompileComponent(""" + public interface IComponent + { + int Method(int value); + } + """, temporaryDirectory); + + string missingDebugReproDirectory = Path.Combine(temporaryDirectory, "does-not-exist"); + + return + [ + $"--input-assembly-path {inputAssemblyPath}", + $"--reference-assembly-paths {inputAssemblyPath}", + $"--output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}", + "--assembly-version 1.0.0.0", + "--use-windows-ui-xaml-projections False", + $"--debug-repro-directory {missingDebugReproDirectory}", + ]; + }); + + Assert.AreNotEqual(0, exitCode, output); + StringAssert.Contains(output, "CSWINRTWINMDGEN0008"); + } +} From 083cc5bb507353e4ab12434a93918a701d233493 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 12:02:57 -0700 Subject: [PATCH 05/12] Move WinMD generator TypeSignature helpers into an extension file Move StripCustomModifiers (previously a private method in WinMDWriter.TypeMapping) and the span predicates into a new Extensions/TypeSignatureExtensions.cs, matching the existing TypeDefinitionExtensions pattern. Rename IsSpan/IsReadOnlySpan to IsTypeOfSpan/IsTypeOfReadOnlySpan as extension methods, consistent with the interop generator's IsTypeOf* extensions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/TypeSignatureExtensions.cs | 54 +++++++++++++++++++ .../Writers/WinMDWriter.Members.cs | 28 ++-------- .../Writers/WinMDWriter.TypeMapping.cs | 22 +------- 3 files changed, 59 insertions(+), 45 deletions(-) create mode 100644 src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs diff --git a/src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs b/src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs new file mode 100644 index 000000000..20e00e309 --- /dev/null +++ b/src/WinRT.WinMD.Generator/Extensions/TypeSignatureExtensions.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using AsmResolver.DotNet.Signatures; + +namespace WindowsRuntime.WinMDGenerator; + +/// +/// Extension methods for . +/// +internal static class TypeSignatureExtensions +{ + extension(TypeSignature signature) + { + /// + /// Strips any leading custom modifiers (modreq/modopt) from the signature. + /// + /// + /// Custom modifiers carry no Windows Runtime meaning. They are emitted by the C# compiler in a few + /// cases, most notably the modreq(System.Runtime.InteropServices.InAttribute) applied to the + /// by-reference type of an in parameter on abstract, virtual, interface, or delegate members. + /// + /// The underlying with all leading custom modifiers removed. + public TypeSignature StripCustomModifiers() + { + TypeSignature current = signature; + + while (current is CustomModifierTypeSignature customModifier) + { + current = customModifier.BaseType; + } + + return current; + } + + /// + /// Checks whether the signature is some type. + /// + /// if the signature is ; otherwise, . + public bool IsTypeOfSpan() + { + return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.Span`1" }; + } + + /// + /// Checks whether the signature is some type. + /// + /// if the signature is ; otherwise, . + public bool IsTypeOfReadOnlySpan() + { + return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.ReadOnlySpan`1" }; + } + } +} diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs index d44aaf9d4..521982d00 100644 --- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs +++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.Members.cs @@ -193,7 +193,7 @@ private static ParameterAttributes GetWinRTParameterAttributes( { // Look through any custom modifiers (e.g. the 'modreq(InAttribute)' emitted for 'in' parameters // on abstract, virtual, interface, or delegate members) to inspect the real underlying signature - TypeSignature parameterType = StripCustomModifiers(inputParamType); + TypeSignature parameterType = inputParamType.StripCustomModifiers(); // Validate by-reference parameters that wrap a span or array. Windows Runtime spans are always // passed by value, and Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' @@ -201,9 +201,9 @@ private static ParameterAttributes GetWinRTParameterAttributes( // 'ref'/'in' array, has no Windows Runtime representation and is rejected here. if (parameterType is ByReferenceTypeSignature byReference) { - TypeSignature elementType = StripCustomModifiers(byReference.BaseType); + TypeSignature elementType = byReference.BaseType.StripCustomModifiers(); - if (IsSpan(elementType) || IsReadOnlySpan(elementType)) + if (elementType.IsTypeOfSpan() || elementType.IsTypeOfReadOnlySpan()) { throw WellKnownWinMDExceptions.ByReferenceSpanParameterNotSupported( inputMethod.DeclaringType!.FullName, @@ -231,7 +231,7 @@ private static ParameterAttributes GetWinRTParameterAttributes( } // 'Span' → 'FillArray' pattern: '[out]' without 'BYREF' - if (IsSpan(parameterType)) + if (parameterType.IsTypeOfSpan()) { return ParameterAttributes.Out; } @@ -242,26 +242,6 @@ private static ParameterAttributes GetWinRTParameterAttributes( return ParameterAttributes.In; } - /// - /// Determines whether a type signature is . - /// - /// The type signature to test. - /// if is ; otherwise, . - private static bool IsSpan(TypeSignature signature) - { - return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.Span`1" }; - } - - /// - /// Determines whether a type signature is . - /// - /// The type signature to test. - /// if is ; otherwise, . - private static bool IsReadOnlySpan(TypeSignature signature) - { - return signature is GenericInstanceTypeSignature { GenericType.FullName: "System.ReadOnlySpan`1" }; - } - /// /// Adds a property definition to a WinMD type (interface or class). /// diff --git a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs index 854994016..530f9ae03 100644 --- a/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs +++ b/src/WinRT.WinMD.Generator/Writers/WinMDWriter.TypeMapping.cs @@ -44,7 +44,7 @@ private TypeSignature MapTypeSignatureToOutput(TypeSignature inputSignature) // modifiers have no Windows Runtime representation, and without stripping them a modifier-wrapped // type would fall through to the 'System.Object' fallback at the end of this method, silently // erasing the real signature. - inputSignature = StripCustomModifiers(inputSignature); + inputSignature = inputSignature.StripCustomModifiers(); // Handle 'CorLib' types if (inputSignature is CorLibTypeSignature corLib) @@ -147,26 +147,6 @@ private TypeSignature MapTypeSignatureToOutput(TypeSignature inputSignature) return _outputModule.CorLibTypeFactory.Object; } - /// - /// Strips any leading custom modifiers (modreq/modopt) from a type signature. - /// - /// - /// Custom modifiers carry no Windows Runtime meaning. They are emitted by the C# compiler in a few - /// cases, most notably the modreq(System.Runtime.InteropServices.InAttribute) applied to the - /// by-reference type of an in parameter on abstract, virtual, interface, or delegate members. - /// - /// The type signature to unwrap. - /// The underlying with all leading custom modifiers removed. - private static TypeSignature StripCustomModifiers(TypeSignature signature) - { - while (signature is CustomModifierTypeSignature customModifier) - { - signature = customModifier.BaseType; - } - - return signature; - } - /// /// Imports a type reference from the input module to the output module. /// From 431eeed70d5d8d568ed290b24251ded03efd4c56 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 13:38:50 -0700 Subject: [PATCH 06/12] Add AssertSuccess/AssertFailure helpers to collapse WinMD generator tests Rename the test helper to WinMDGeneratorRunner and give it AssertSuccess/AssertFailure entry points that run the generator and make the exit-code and error-output assertions, so each test is a single call. The run mechanics (Run, RunTool, GetGeneratorPath, the failure-result assertion, cleanup) are private; only the assertion entry points and CompileComponent (used by the response-file factory scenarios) are public. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rTestHelper.cs => WinMDGeneratorRunner.cs} | 134 ++++++++++++------ .../WinMDGeneratorTest/Test_InvalidInputs.cs | 65 +++------ .../Test_ParameterConventions.cs | 35 ++--- 3 files changed, 119 insertions(+), 115 deletions(-) rename src/Tests/WinMDGeneratorTest/Helpers/{WinMDGeneratorTestHelper.cs => WinMDGeneratorRunner.cs} (65%) diff --git a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs similarity index 65% rename from src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs rename to src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs index 75dc8dc24..5c6f02314 100644 --- a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorTestHelper.cs +++ b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs @@ -15,38 +15,40 @@ namespace WinMDGeneratorTest.Helpers; /// -/// Helpers to drive the WinMD generator (cswinrtwinmdgen) end-to-end: compile a small C# input, -/// run the actual tool against it as a separate process, and capture its result. +/// Runs the WinMD generator (cswinrtwinmdgen) end-to-end and asserts its outcome. /// -internal static class WinMDGeneratorTestHelper +/// +/// Each entry point compiles a small C# input (or builds a raw response file), runs the actual tool as +/// a separate process, and asserts the result. Tests should call or one of +/// the AssertFailure overloads so each scenario stays a single call. +/// +internal static class WinMDGeneratorRunner { /// - /// Compiles the given C# into an assembly and runs the WinMD generator - /// against it with a standard response file, returning the process exit code and combined output. + /// Asserts that the generator succeeds (exit code 0) for the given component source. /// /// The C# source defining the authored component types. /// Whether to use the UWP (Windows.UI.Xaml) projections. - /// The generator process exit code and its combined standard output and error. - public static (int ExitCode, string Output) RunGenerator(string source, bool useWindowsUIXamlProjections = false) + public static void AssertSuccess(string source, bool useWindowsUIXamlProjections = false) { - return RunGenerator(temporaryDirectory => - { - string inputAssemblyPath = CompileComponent(source, temporaryDirectory); + (int exitCode, string output) = Run(source, useWindowsUIXamlProjections); - return - [ - $"--input-assembly-path {inputAssemblyPath}", - $"--reference-assembly-paths {inputAssemblyPath}", - $"--output-winmd-path {Path.Combine(temporaryDirectory, "TestInput.winmd")}", - "--assembly-version 1.0.0.0", - $"--use-windows-ui-xaml-projections {useWindowsUIXamlProjections}", - ]; - }); + Assert.AreEqual(0, exitCode, output); + } + + /// + /// Asserts that the generator fails for the given component source, reporting the expected error. + /// + /// The C# source defining the authored component types. + /// The expected error id (e.g. "CSWINRTWINMDGEN0011") the output must contain. + /// Whether to use the UWP (Windows.UI.Xaml) projections. + public static void AssertFailure(string source, string error, bool useWindowsUIXamlProjections = false) + { + AssertFailureResult(Run(source, useWindowsUIXamlProjections), error); } /// - /// Runs the WinMD generator against a caller-provided response file, returning the process exit - /// code and combined output. + /// Asserts that the generator fails for a caller-provided response file, reporting the expected error. /// /// /// The receives a fresh temporary working directory and @@ -54,41 +56,32 @@ public static (int ExitCode, string Output) RunGenerator(string source, bool use /// an input assembly, or reference any other path under the directory (e.g. to test invalid inputs). /// /// Builds the response file lines for a given temporary directory. - /// The generator process exit code and its combined standard output and error. - public static (int ExitCode, string Output) RunGenerator(Func> responseFileFactory) + /// The expected error id (e.g. "CSWINRTWINMDGEN0002") the output must contain. + public static void AssertFailure(Func> responseFileFactory, string error) { - string toolPath = GetGeneratorPath(); - string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName; - - try - { - IReadOnlyList responseFileLines = responseFileFactory(temporaryDirectory); - string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp"); - - File.WriteAllLines(responseFilePath, responseFileLines); - - return RunTool(toolPath, responseFilePath); - } - finally - { - TryDeleteDirectory(temporaryDirectory); - } + AssertFailureResult(Run(responseFileFactory), error); } /// - /// Runs the WinMD generator pointed at a response file path that does not exist. + /// Asserts that the generator fails when pointed at a response file path that does not exist, + /// reporting the expected error. /// - /// The generator process exit code and its combined standard output and error. - public static (int ExitCode, string Output) RunGeneratorWithMissingResponseFile() + /// The expected error id (e.g. "CSWINRTWINMDGEN0001") the output must contain. + public static void AssertFailureForMissingResponseFile(string error) { string missingResponseFilePath = Path.Combine(Path.GetTempPath(), $"WinMDGeneratorTest_{Guid.NewGuid():N}.rsp"); - return RunTool(GetGeneratorPath(), missingResponseFilePath); + AssertFailureResult(RunTool(GetGeneratorPath(), missingResponseFilePath), error); } /// /// Compiles the given C# into TestInput.dll in . /// + /// + /// This is exposed for the + /// scenarios that need a valid input assembly before triggering a later-stage failure (e.g. an + /// unwritable output path). + /// /// The C# source defining the authored component types. /// The directory to emit the assembly into. /// The full path to the compiled assembly. @@ -119,6 +112,59 @@ public static string CompileComponent(string source, string directory) return outputPath; } + /// + /// Compiles the given C# into an assembly and runs the WinMD generator + /// against it with a standard response file. + /// + private static (int ExitCode, string Output) Run(string source, bool useWindowsUIXamlProjections) + { + return Run(temporaryDirectory => + { + string inputAssemblyPath = CompileComponent(source, temporaryDirectory); + + return + [ + $"--input-assembly-path {inputAssemblyPath}", + $"--reference-assembly-paths {inputAssemblyPath}", + $"--output-winmd-path {Path.Combine(temporaryDirectory, "TestInput.winmd")}", + "--assembly-version 1.0.0.0", + $"--use-windows-ui-xaml-projections {useWindowsUIXamlProjections}", + ]; + }); + } + + /// + /// Runs the WinMD generator against a caller-provided response file. + /// + private static (int ExitCode, string Output) Run(Func> responseFileFactory) + { + string toolPath = GetGeneratorPath(); + string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName; + + try + { + IReadOnlyList responseFileLines = responseFileFactory(temporaryDirectory); + string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp"); + + File.WriteAllLines(responseFilePath, responseFileLines); + + return RunTool(toolPath, responseFilePath); + } + finally + { + TryDeleteDirectory(temporaryDirectory); + } + } + + /// + /// Asserts that a generator run failed (non-zero exit code) and its output contains the expected error. + /// + private static void AssertFailureResult((int ExitCode, string Output) result, string error) + { + Assert.AreNotEqual(0, result.ExitCode, result.Output); + StringAssert.Contains(result.Output, error); + } + /// /// Runs dotnet exec <tool> <argument> and captures the exit code and output. /// @@ -151,7 +197,7 @@ private static (int ExitCode, string Output) RunTool(string toolPath, string arg /// private static string GetGeneratorPath() { - string? path = typeof(WinMDGeneratorTestHelper).Assembly + string? path = typeof(WinMDGeneratorRunner).Assembly .GetCustomAttributes() .FirstOrDefault(static attribute => attribute.Key == "WinMDGeneratorAssemblyPath")?.Value; diff --git a/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs index 35d9b6349..9d434003f 100644 --- a/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs +++ b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs @@ -21,75 +21,60 @@ public class Test_InvalidInputs [TestMethod] public void MissingResponseFile_IsReported() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGeneratorWithMissingResponseFile(); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0001"); + WinMDGeneratorRunner.AssertFailureForMissingResponseFile(error: "CSWINRTWINMDGEN0001"); } [TestMethod] public void MalformedResponseFile_IsReported() { // A response file line must be a ' ' pair; a line without a space is invalid - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + WinMDGeneratorRunner.AssertFailure(static _ => [ "--assembly-version 1.0.0.0", "this-line-has-no-space", - ]); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0002"); + ], error: "CSWINRTWINMDGEN0002"); } [TestMethod] public void DuplicateArgument_IsReported() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + WinMDGeneratorRunner.AssertFailure(static _ => [ "--assembly-version 1.0.0.0", "--assembly-version 2.0.0.0", - ]); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0002"); + ], error: "CSWINRTWINMDGEN0002"); } [TestMethod] public void MissingRequiredArgument_IsReported() { // The required '--output-winmd-path' argument is omitted - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + WinMDGeneratorRunner.AssertFailure(static _ => [ "--input-assembly-path input.dll", "--reference-assembly-paths input.dll", "--assembly-version 1.0.0.0", "--use-windows-ui-xaml-projections False", - ]); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0003"); + ], error: "CSWINRTWINMDGEN0003"); } [TestMethod] public void InvalidBooleanArgument_IsReported() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static _ => + WinMDGeneratorRunner.AssertFailure(static _ => [ "--input-assembly-path input.dll", "--reference-assembly-paths input.dll", "--output-winmd-path output.winmd", "--assembly-version 1.0.0.0", "--use-windows-ui-xaml-projections not-a-boolean", - ]); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0003"); + ], error: "CSWINRTWINMDGEN0003"); } [TestMethod] public void MissingInputAssembly_IsReported() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + WinMDGeneratorRunner.AssertFailure(static temporaryDirectory => { string missingInput = Path.Combine(temporaryDirectory, "DoesNotExist.dll"); @@ -101,16 +86,13 @@ public void MissingInputAssembly_IsReported() "--assembly-version 1.0.0.0", "--use-windows-ui-xaml-projections False", ]; - }); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0004"); + }, error: "CSWINRTWINMDGEN0004"); } [TestMethod] public void InvalidInputAssembly_IsReported() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + WinMDGeneratorRunner.AssertFailure(static temporaryDirectory => { string invalidInput = Path.Combine(temporaryDirectory, "NotAPortableExecutable.dll"); @@ -124,19 +106,16 @@ public void InvalidInputAssembly_IsReported() "--assembly-version 1.0.0.0", "--use-windows-ui-xaml-projections False", ]; - }); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0004"); + }, error: "CSWINRTWINMDGEN0004"); } [TestMethod] public void UnwritableOutputPath_IsReported() { // The output path points at an existing directory, so the '.winmd' cannot be written - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + WinMDGeneratorRunner.AssertFailure(static temporaryDirectory => { - string inputAssemblyPath = WinMDGeneratorTestHelper.CompileComponent(""" + string inputAssemblyPath = WinMDGeneratorRunner.CompileComponent(""" public interface IComponent { int Method(int value); @@ -151,18 +130,15 @@ public interface IComponent "--assembly-version 1.0.0.0", "--use-windows-ui-xaml-projections False", ]; - }); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0006"); + }, error: "CSWINRTWINMDGEN0006"); } [TestMethod] public void MissingDebugReproDirectory_IsReported() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(static temporaryDirectory => + WinMDGeneratorRunner.AssertFailure(static temporaryDirectory => { - string inputAssemblyPath = WinMDGeneratorTestHelper.CompileComponent(""" + string inputAssemblyPath = WinMDGeneratorRunner.CompileComponent(""" public interface IComponent { int Method(int value); @@ -180,9 +156,6 @@ public interface IComponent "--use-windows-ui-xaml-projections False", $"--debug-repro-directory {missingDebugReproDirectory}", ]; - }); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0008"); + }, error: "CSWINRTWINMDGEN0008"); } } diff --git a/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs b/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs index 7398c2781..049ae24e5 100644 --- a/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs +++ b/src/Tests/WinMDGeneratorTest/Test_ParameterConventions.cs @@ -20,30 +20,24 @@ public class Test_ParameterConventions [TestMethod] public void ValidComponent_GeneratesSuccessfully() { - // Smoke test: a valid component generates a '.winmd' (exit code 0). This validates the - // end-to-end harness so the failure assertions below are meaningful. - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + // Smoke test: a valid component generates a '.winmd', validating the end-to-end harness + WinMDGeneratorRunner.AssertSuccess(""" public interface IComponent { int Method(int value); } """); - - Assert.AreEqual(0, exitCode, output); } [TestMethod] public void RefArrayParameter_IsRejected() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + WinMDGeneratorRunner.AssertFailure(""" public interface IComponent { void Method(ref int[] values); } - """); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0011"); + """, error: "CSWINRTWINMDGEN0011"); } [TestMethod] @@ -51,44 +45,35 @@ public void InArrayParameter_IsRejected() { // 'in int[]' carries a 'modreq(InAttribute)' on interface members; the generator must still // see through it to detect the by-reference array. - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + WinMDGeneratorRunner.AssertFailure(""" public interface IComponent { void Method(in int[] values); } - """); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0011"); + """, error: "CSWINRTWINMDGEN0011"); } [TestMethod] public void OutSpanParameter_IsRejected() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + WinMDGeneratorRunner.AssertFailure(""" using System; public interface IComponent { void Method(out Span values); } - """); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0012"); + """, error: "CSWINRTWINMDGEN0012"); } [TestMethod] public void OutReadOnlySpanParameter_IsRejected() { - (int exitCode, string output) = WinMDGeneratorTestHelper.RunGenerator(""" + WinMDGeneratorRunner.AssertFailure(""" using System; public interface IComponent { void Method(out ReadOnlySpan values); } - """); - - Assert.AreNotEqual(0, exitCode, output); - StringAssert.Contains(output, "CSWINRTWINMDGEN0012"); + """, error: "CSWINRTWINMDGEN0012"); } } From 4e5283d2f2e74148d4bfc48adcb108c12c4c40c3 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 13:42:52 -0700 Subject: [PATCH 07/12] Take full response file content as a single string in WinMD generator tests Change the AssertFailure response-file factory from Func> to Func so callers provide the entire '.rsp' content as one multiline raw interpolated string instead of a list of per-line expressions. The runner now writes the content verbatim with File.WriteAllText. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/WinMDGeneratorRunner.cs | 33 +++--- .../WinMDGeneratorTest/Test_InvalidInputs.cs | 108 ++++++++---------- 2 files changed, 66 insertions(+), 75 deletions(-) diff --git a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs index 5c6f02314..aaf4516f9 100644 --- a/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs +++ b/src/Tests/WinMDGeneratorTest/Helpers/WinMDGeneratorRunner.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -52,12 +51,13 @@ public static void AssertFailure(string source, string error, bool useWindowsUIX /// /// /// The receives a fresh temporary working directory and - /// returns the response file lines to run with. It can use to produce - /// an input assembly, or reference any other path under the directory (e.g. to test invalid inputs). + /// returns the full response file content to run with (one --argument value pair per line). It + /// can use to produce an input assembly, or reference any other path + /// under the directory (e.g. to test invalid inputs). /// - /// Builds the response file lines for a given temporary directory. + /// Builds the full response file content for a given temporary directory. /// The expected error id (e.g. "CSWINRTWINMDGEN0002") the output must contain. - public static void AssertFailure(Func> responseFileFactory, string error) + public static void AssertFailure(Func responseFileFactory, string error) { AssertFailureResult(Run(responseFileFactory), error); } @@ -78,7 +78,7 @@ public static void AssertFailureForMissingResponseFile(string error) /// Compiles the given C# into TestInput.dll in . /// /// - /// This is exposed for the + /// This is exposed for the /// scenarios that need a valid input assembly before triggering a later-stage failure (e.g. an /// unwritable output path). /// @@ -122,31 +122,30 @@ private static (int ExitCode, string Output) Run(string source, bool useWindowsU { string inputAssemblyPath = CompileComponent(source, temporaryDirectory); - return - [ - $"--input-assembly-path {inputAssemblyPath}", - $"--reference-assembly-paths {inputAssemblyPath}", - $"--output-winmd-path {Path.Combine(temporaryDirectory, "TestInput.winmd")}", - "--assembly-version 1.0.0.0", - $"--use-windows-ui-xaml-projections {useWindowsUIXamlProjections}", - ]; + return $""" + --input-assembly-path {inputAssemblyPath} + --reference-assembly-paths {inputAssemblyPath} + --output-winmd-path {Path.Combine(temporaryDirectory, "TestInput.winmd")} + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections {useWindowsUIXamlProjections} + """; }); } /// /// Runs the WinMD generator against a caller-provided response file. /// - private static (int ExitCode, string Output) Run(Func> responseFileFactory) + private static (int ExitCode, string Output) Run(Func responseFileFactory) { string toolPath = GetGeneratorPath(); string temporaryDirectory = Directory.CreateTempSubdirectory("WinMDGeneratorTest_").FullName; try { - IReadOnlyList responseFileLines = responseFileFactory(temporaryDirectory); + string responseFileContent = responseFileFactory(temporaryDirectory); string responseFilePath = Path.Combine(temporaryDirectory, "args.rsp"); - File.WriteAllLines(responseFilePath, responseFileLines); + File.WriteAllText(responseFilePath, responseFileContent); return RunTool(toolPath, responseFilePath); } diff --git a/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs index 9d434003f..d48a91e36 100644 --- a/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs +++ b/src/Tests/WinMDGeneratorTest/Test_InvalidInputs.cs @@ -28,47 +28,43 @@ public void MissingResponseFile_IsReported() public void MalformedResponseFile_IsReported() { // A response file line must be a ' ' pair; a line without a space is invalid - WinMDGeneratorRunner.AssertFailure(static _ => - [ - "--assembly-version 1.0.0.0", - "this-line-has-no-space", - ], error: "CSWINRTWINMDGEN0002"); + WinMDGeneratorRunner.AssertFailure(static _ => """ + --assembly-version 1.0.0.0 + this-line-has-no-space + """, error: "CSWINRTWINMDGEN0002"); } [TestMethod] public void DuplicateArgument_IsReported() { - WinMDGeneratorRunner.AssertFailure(static _ => - [ - "--assembly-version 1.0.0.0", - "--assembly-version 2.0.0.0", - ], error: "CSWINRTWINMDGEN0002"); + WinMDGeneratorRunner.AssertFailure(static _ => """ + --assembly-version 1.0.0.0 + --assembly-version 2.0.0.0 + """, error: "CSWINRTWINMDGEN0002"); } [TestMethod] public void MissingRequiredArgument_IsReported() { // The required '--output-winmd-path' argument is omitted - WinMDGeneratorRunner.AssertFailure(static _ => - [ - "--input-assembly-path input.dll", - "--reference-assembly-paths input.dll", - "--assembly-version 1.0.0.0", - "--use-windows-ui-xaml-projections False", - ], error: "CSWINRTWINMDGEN0003"); + WinMDGeneratorRunner.AssertFailure(static _ => """ + --input-assembly-path input.dll + --reference-assembly-paths input.dll + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections False + """, error: "CSWINRTWINMDGEN0003"); } [TestMethod] public void InvalidBooleanArgument_IsReported() { - WinMDGeneratorRunner.AssertFailure(static _ => - [ - "--input-assembly-path input.dll", - "--reference-assembly-paths input.dll", - "--output-winmd-path output.winmd", - "--assembly-version 1.0.0.0", - "--use-windows-ui-xaml-projections not-a-boolean", - ], error: "CSWINRTWINMDGEN0003"); + WinMDGeneratorRunner.AssertFailure(static _ => """ + --input-assembly-path input.dll + --reference-assembly-paths input.dll + --output-winmd-path output.winmd + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections not-a-boolean + """, error: "CSWINRTWINMDGEN0003"); } [TestMethod] @@ -78,14 +74,13 @@ public void MissingInputAssembly_IsReported() { string missingInput = Path.Combine(temporaryDirectory, "DoesNotExist.dll"); - return - [ - $"--input-assembly-path {missingInput}", - $"--reference-assembly-paths {missingInput}", - $"--output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}", - "--assembly-version 1.0.0.0", - "--use-windows-ui-xaml-projections False", - ]; + return $""" + --input-assembly-path {missingInput} + --reference-assembly-paths {missingInput} + --output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")} + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections False + """; }, error: "CSWINRTWINMDGEN0004"); } @@ -98,14 +93,13 @@ public void InvalidInputAssembly_IsReported() File.WriteAllText(invalidInput, "This is not a valid PE image."); - return - [ - $"--input-assembly-path {invalidInput}", - $"--reference-assembly-paths {invalidInput}", - $"--output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}", - "--assembly-version 1.0.0.0", - "--use-windows-ui-xaml-projections False", - ]; + return $""" + --input-assembly-path {invalidInput} + --reference-assembly-paths {invalidInput} + --output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")} + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections False + """; }, error: "CSWINRTWINMDGEN0004"); } @@ -122,14 +116,13 @@ public interface IComponent } """, temporaryDirectory); - return - [ - $"--input-assembly-path {inputAssemblyPath}", - $"--reference-assembly-paths {inputAssemblyPath}", - $"--output-winmd-path {temporaryDirectory}", - "--assembly-version 1.0.0.0", - "--use-windows-ui-xaml-projections False", - ]; + return $""" + --input-assembly-path {inputAssemblyPath} + --reference-assembly-paths {inputAssemblyPath} + --output-winmd-path {temporaryDirectory} + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections False + """; }, error: "CSWINRTWINMDGEN0006"); } @@ -147,15 +140,14 @@ public interface IComponent string missingDebugReproDirectory = Path.Combine(temporaryDirectory, "does-not-exist"); - return - [ - $"--input-assembly-path {inputAssemblyPath}", - $"--reference-assembly-paths {inputAssemblyPath}", - $"--output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")}", - "--assembly-version 1.0.0.0", - "--use-windows-ui-xaml-projections False", - $"--debug-repro-directory {missingDebugReproDirectory}", - ]; + return $""" + --input-assembly-path {inputAssemblyPath} + --reference-assembly-paths {inputAssemblyPath} + --output-winmd-path {Path.Combine(temporaryDirectory, "out.winmd")} + --assembly-version 1.0.0.0 + --use-windows-ui-xaml-projections False + --debug-repro-directory {missingDebugReproDirectory} + """; }, error: "CSWINRTWINMDGEN0008"); } } From 26431d9b969d096d2c6c400a53d1a561dec8077d Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 13:47:55 -0700 Subject: [PATCH 08/12] Document the WinMDGeneratorTest project in the testing skill Add a 'WinMD generator tests' section describing the new src/Tests/WinMDGeneratorTest project (end-to-end failure-case tests for cswinrtwinmdgen), add a routing-table row, and bump the primary test area count to 6. Also add a matching per-project verification step to the update-testing-instructions skill and renumber the subsequent steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/testing/SKILL.md | 42 ++++++++++++++++++- .../update-testing-instructions/SKILL.md | 27 ++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md index c735aa5d2..b4d7fb8a2 100644 --- a/.github/skills/testing/SKILL.md +++ b/.github/skills/testing/SKILL.md @@ -13,7 +13,7 @@ Before adding tests, always check whether tests for the same functionality alrea ## Test project overview -CsWinRT 3.0 has 5 primary test project areas, each serving a different purpose. Additional specialized test projects also exist under `src/Tests/`: +CsWinRT 3.0 has 6 primary test project areas, each serving a different purpose. Additional specialized test projects also exist under `src/Tests/`: ### 1. Unit tests (`src/Tests/UnitTest/`) @@ -213,6 +213,45 @@ public async Task InvalidType_Warns() - **Release x64:** NativeAOT self-contained mode - Build-time validation (compilation succeeds = test passes) +### 6. WinMD generator tests (`src/Tests/WinMDGeneratorTest/`) + +**What it tests:** End-to-end behavior of the `cswinrtwinmdgen` build tool (the WinMD generator). It focuses on **failure cases** — unsupported authored component shapes and invalid tool invocations — that can be asserted without breaking a real build. + +**When to add tests here:** For testing a WinMD generator failure mode — a new `CSWINRTWINMDGEN` validation error, an unsupported authoring pattern, or a malformed/invalid invocation. Authored shapes that produce a valid `.winmd` are covered by the authoring tests (`AuthoringTest/`) instead. + +**Project settings:** +- **Test framework:** MSTest (`[TestClass]`, `[TestMethod]`, `Assert.*`) +- **TFM:** `net10.0`, multi-platform (x64/x86) +- **Output type:** Exe, `CsWinRTEnabled=false` +- **Key dependencies:** MSTest packages, `Basic.Reference.Assemblies.Net100` and `Microsoft.CodeAnalysis.CSharp` (used to compile the C# inputs) +- **References:** `WinRT.WinMD.Generator` via `ProjectReference` with `ReferenceOutputAssembly="false"` and `GlobalPropertiesToRemove="RuntimeIdentifier"` — the tool is **built but not referenced**, and tests invoke it as a separate process. Its path is passed to the tests via an `AssemblyMetadata` item (`WinMDGeneratorAssemblyPath`). + +**Test classes:** +| Test class | What it tests | +|------------|---------------| +| `Test_ParameterConventions` | Unsupported array/span parameter shapes (`ref`/`in` arrays, `out` spans) → `CSWINRTWINMDGEN0011`/`0012`, plus a valid smoke test | +| `Test_InvalidInputs` | Invalid invocations: malformed/missing response files, bad arguments, missing/corrupt input assemblies, an unwritable output path, a missing debug-repro directory | + +**Test helper (in `Helpers/`):** +- `WinMDGeneratorRunner` — compiles a C# input, runs the actual tool as a subprocess, and asserts the outcome. Public entry points: `AssertSuccess`, `AssertFailure` (from component source or full response-file content), `AssertFailureForMissingResponseFile`, and `CompileComponent`. + +**Pattern:** +```csharp +[TestMethod] +public void RefArrayParameter_IsRejected() +{ + WinMDGeneratorRunner.AssertFailure(""" + public interface IComponent + { + void Method(ref int[] values); + } + """, error: "CSWINRTWINMDGEN0011"); +} +``` + +- Each test is a single `AssertSuccess`/`AssertFailure` call; the runner makes the exit-code and error-output assertions +- Failure cases assert the tool exits non-zero and its output contains the expected `CSWINRTWINMDGEN` error id + ## Deciding where to add tests | You want to test... | Add test to... | @@ -227,6 +266,7 @@ public async Task InvalidType_Warns() | GC/reference tracking behavior | `ObjectLifetimeTests/` | | XAML visual tree element lifetime | `ObjectLifetimeTests/` | | WinRT component authoring patterns | `AuthoringTest/` (when enabled) | +| A WinMD generator failure mode (a `CSWINRTWINMDGEN` error) | `WinMDGeneratorTest/` (add to `Test_ParameterConventions` or `Test_InvalidInputs`) | | Generated projection code patterns or cross-ABI control flow | Update `TestComponentCSharp/` and add tests in `UnitTest/` or `FunctionalTests/` | ## Test component: TestComponentCSharp (`src/Tests/TestComponentCSharp/`) diff --git a/.github/skills/update-testing-instructions/SKILL.md b/.github/skills/update-testing-instructions/SKILL.md index 6ffe5ff9c..a304f0931 100644 --- a/.github/skills/update-testing-instructions/SKILL.md +++ b/.github/skills/update-testing-instructions/SKILL.md @@ -76,7 +76,19 @@ Launch an explore agent to verify: - **"Referenced from" list** is current - **"When to update" guidance** is still correct -### Step 8: verify the "deciding where to add tests" table +### Step 8: verify WinMD generator tests (`src/Tests/WinMDGeneratorTest/`) + +Launch an explore agent to analyze the WinMD generator test project. Verify: + +- **Project settings** are current: test framework, TFM, platforms, output type, `CsWinRTEnabled` setting +- **Tool wiring** is accurate: the `WinRT.WinMD.Generator` `ProjectReference` is built but not referenced (`ReferenceOutputAssembly="false"` + `GlobalPropertiesToRemove="RuntimeIdentifier"`), and the `AssemblyMetadata` item (`WinMDGeneratorAssemblyPath`) passes the built tool's path to the tests +- **Key dependencies** are current (MSTest packages, `Basic.Reference.Assemblies.Net100`, `Microsoft.CodeAnalysis.CSharp`) +- **Test class table** is complete and accurate — check all `Test_*.cs` files, verify no classes have been added, removed, or renamed, and verify each class's description matches its actual content +- **Test helper** (`WinMDGeneratorRunner` in `Helpers/`) is accurately described, including its public entry points (`AssertSuccess`, `AssertFailure`, `AssertFailureForMissingResponseFile`, `CompileComponent`) +- **Pattern example** matches the actual single-call `AssertSuccess`/`AssertFailure` style +- **Error ids** referenced (`CSWINRTWINMDGEN*`) are still produced by the generator + +### Step 9: verify the "deciding where to add tests" table Check every row in the routing table against the actual test projects. Verify: @@ -85,7 +97,7 @@ Check every row in the routing table against the actual test projects. Verify: - No new test project categories exist that should be added as rows - The `TestComponentCSharp` guidance is still accurate -### Step 9: verify code examples +### Step 10: verify code examples For each code example in the testing skill, verify it matches the conventions actually used in the test files: @@ -93,8 +105,9 @@ For each code example in the testing skill, verify it matches the conventions ac - **Functional test pattern**: verify the example uses the correct exit codes and top-level statement style - **Generator test pattern**: verify the `VerifySources` call matches the actual method signature and style. Check an actual test method in `Test_CustomPropertyProviderGenerator` to confirm - **Analyzer test pattern**: verify the `VerifyAnalyzerAsync` call matches the actual method signature and style. Check an actual test method in the analyzer test files to confirm +- **WinMD generator test pattern**: verify the single-call `AssertSuccess`/`AssertFailure` example matches the actual style. Check an actual test method in `Test_ParameterConventions` or `Test_InvalidInputs` to confirm -### Step 10: update the testing instructions +### Step 11: update the testing instructions Apply surgical edits to `.github/skills/testing/SKILL.md` to fix any discrepancies found. Typical updates include: @@ -115,17 +128,17 @@ Apply surgical edits to `.github/skills/testing/SKILL.md` to fix any discrepanci - Do not add unnecessary commentary or explanation beyond what's needed for test placement guidance -### Step 11: update this skill if needed +### Step 12: update this skill if needed If significant changes to the test suite were discovered (e.g. test projects added or removed, new categories of tests, changed validation criteria), also update this skill file (`.github/skills/update-testing-instructions/SKILL.md`) to reflect those changes. In particular: -- The **per-project verification steps** (steps 2–7) must stay in sync with the actual test projects. If a test project is added or removed, add or remove its verification step accordingly. +- The **per-project verification steps** (steps 2–8) must stay in sync with the actual test projects. If a test project is added or removed, add or remove its verification step accordingly. - The **verification criteria** for each step should reflect what is actually worth checking. If a project gains new aspects worth validating (e.g. new test helper classes, new build settings), add those to the checklist. -- The **code example verification step** (step 9) should list all code examples present in the testing skill. +- The **code example verification step** (step 10) should list all code examples present in the testing skill. This ensures the skill remains useful and accurate for future runs. -### Step 12: summarize changes +### Step 13: summarize changes After editing, provide a clear summary of what was updated and why, so the user can review the changes before committing. From b762e95f2d740312b593d482c3a2736e20310ade Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 13:47:55 -0700 Subject: [PATCH 09/12] Fix stale comment in WinMDGeneratorTest project file The tests run the generator tool end-to-end as a subprocess rather than exercising its managed logic in-process, so update the comment that justifies 'CsWinRTEnabled=false' to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj index c70c6a443..0ae7ab50b 100644 --- a/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj +++ b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj @@ -7,7 +7,7 @@ enable false - + false From ba18e42601bef7b1bb1b20999430d8125167ee42 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 13:57:25 -0700 Subject: [PATCH 10/12] Move new WinMD generator errors after the existing ones Define the by-reference array/span parameter errors (0011/0012) after the debug-repro errors (0008-0010) instead of in the middle, so the methods appear in ascending error-id order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Errors/WellKnownWinMDExceptions.cs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs index 01078856c..db39ae00c 100644 --- a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs +++ b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs @@ -73,22 +73,6 @@ public static Exception InputAssemblyRuntimeVersionNotFound(string path) return Exception(7, $"Failed to probe the .NET runtime version from the input assembly '{path}'."); } - /// - /// A method has a ref or in array parameter, which is not a valid Windows Runtime array convention. - /// - public static Exception ByReferenceArrayParameterNotSupported(string declaringTypeName, string methodName, string parameterName) - { - return Exception(11, $"Method '{declaringTypeName}.{methodName}' has by-reference array parameter '{parameterName}' passed by 'ref' or 'in'. Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' for a read-only input array (PassArray), 'Span' for a caller-allocated array (FillArray), or 'out T[]' for a callee-allocated array (ReceiveArray)."); - } - - /// - /// A method has a by-reference span parameter (e.g. out Span<T>), which has no Windows Runtime representation. - /// - public static Exception ByReferenceSpanParameterNotSupported(string declaringTypeName, string methodName, string parameterName) - { - return Exception(12, $"Method '{declaringTypeName}.{methodName}' has by-reference span parameter '{parameterName}'. Windows Runtime spans are passed by value: use 'ReadOnlySpan' (PassArray) or 'Span' (FillArray) by value, or 'out T[]' for a callee-allocated array (ReceiveArray)."); - } - /// public static Exception DebugReproDirectoryDoesNotExist(string path) { @@ -107,6 +91,22 @@ public static Exception DebugReproUnrecognizedFileEntry(string path) return Exception(10, WellKnownGeneratorMessages.DebugReproUnrecognizedFileEntry(path)); } + /// + /// A method has a ref or in array parameter, which is not a valid Windows Runtime array convention. + /// + public static Exception ByReferenceArrayParameterNotSupported(string declaringTypeName, string methodName, string parameterName) + { + return Exception(11, $"Method '{declaringTypeName}.{methodName}' has by-reference array parameter '{parameterName}' passed by 'ref' or 'in'. Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' for a read-only input array (PassArray), 'Span' for a caller-allocated array (FillArray), or 'out T[]' for a callee-allocated array (ReceiveArray)."); + } + + /// + /// A method has a by-reference span parameter (e.g. out Span<T>), which has no Windows Runtime representation. + /// + public static Exception ByReferenceSpanParameterNotSupported(string declaringTypeName, string methodName, string parameterName) + { + return Exception(12, $"Method '{declaringTypeName}.{methodName}' has by-reference span parameter '{parameterName}'. Windows Runtime spans are passed by value: use 'ReadOnlySpan' (PassArray) or 'Span' (FillArray) by value, or 'out T[]' for a callee-allocated array (ReceiveArray)."); + } + /// /// Creates a new exception with the specified id and message. /// From 90a6eac9fd487c5ea4d491a55ed12f83a34c669e Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Tue, 16 Jun 2026 15:23:37 -0700 Subject: [PATCH 11/12] Build the WinMD generator framework-dependent in the test project (NETSDK1151) The generator's PublishAot build is self-contained, which a non self-contained executable cannot reference (NETSDK1151). Replace GlobalPropertiesToRemove for RuntimeIdentifier with UndefineProperties for BuildToolArch, PublishBuildTool, RuntimeIdentifier and SelfContained so the tool is built framework-dependent for the build host, matching the proven WinRT.Internal reference pattern. The output stays under net10.0 (no RID subfolder), so the tool path used by the tests is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WinMDGeneratorTest/WinMDGeneratorTest.csproj | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj index 0ae7ab50b..bc2bb655f 100644 --- a/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj +++ b/src/Tests/WinMDGeneratorTest/WinMDGeneratorTest.csproj @@ -41,13 +41,19 @@ + OutputItemType="_CsWinRTToolReference" + UndefineProperties="BuildToolArch;PublishBuildTool;RuntimeIdentifier;SelfContained" + Private="false" /> From f2415aeb88d14cca76f3b0ce090431fe57971fdf Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 17 Jun 2026 14:10:14 -0700 Subject: [PATCH 12/12] Wrap long exception messages for readability Split long interpolated exception message strings into multi-line concatenated strings in src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs (ByReferenceArrayParameterNotSupported and ByReferenceSpanParameterNotSupported) to improve readability and line-length compliance; no functional behavior changes. --- .../Errors/WellKnownWinMDExceptions.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs index db39ae00c..27e5e752c 100644 --- a/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs +++ b/src/WinRT.WinMD.Generator/Errors/WellKnownWinMDExceptions.cs @@ -96,7 +96,10 @@ public static Exception DebugReproUnrecognizedFileEntry(string path) /// public static Exception ByReferenceArrayParameterNotSupported(string declaringTypeName, string methodName, string parameterName) { - return Exception(11, $"Method '{declaringTypeName}.{methodName}' has by-reference array parameter '{parameterName}' passed by 'ref' or 'in'. Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' for a read-only input array (PassArray), 'Span' for a caller-allocated array (FillArray), or 'out T[]' for a callee-allocated array (ReceiveArray)."); + return Exception(11, + $"Method '{declaringTypeName}.{methodName}' has by-reference array parameter '{parameterName}' passed by 'ref' or 'in'. " + + $"Windows Runtime arrays use one of three conventions: 'ReadOnlySpan' for a read-only input array (PassArray), " + + $"'Span' for a caller-allocated array (FillArray), or 'out T[]' for a callee-allocated array (ReceiveArray)."); } /// @@ -104,7 +107,10 @@ public static Exception ByReferenceArrayParameterNotSupported(string declaringTy /// public static Exception ByReferenceSpanParameterNotSupported(string declaringTypeName, string methodName, string parameterName) { - return Exception(12, $"Method '{declaringTypeName}.{methodName}' has by-reference span parameter '{parameterName}'. Windows Runtime spans are passed by value: use 'ReadOnlySpan' (PassArray) or 'Span' (FillArray) by value, or 'out T[]' for a callee-allocated array (ReceiveArray)."); + return Exception(12, + $"Method '{declaringTypeName}.{methodName}' has by-reference span parameter '{parameterName}'." + + $"Windows Runtime spans are passed by value: use 'ReadOnlySpan' (PassArray) or 'Span' " + + $"(FillArray) by value, or 'out T[]' for a callee-allocated array (ReceiveArray)."); } ///