// Copyright (c) 2026 Christoph Wille // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software // without restriction, including without limitation the rights to use, copy, modify, merge, // publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons // to whom the Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR // PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE // FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.Tracing; using System.Linq; using System.IO; using ICSharpCode.Decompiler.CSharp; using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; using ICSharpCode.Decompiler.Instrumentation; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.TypeSystem; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; namespace ICSharpCode.Decompiler.Tests.Instrumentation { [TestFixture] public class DecompilerEventSourceTests { /// /// Captures all events the "ICSharpCode.Decompiler" provider emits while the listener /// is alive. Events are recorded process-wide, so assertions must filter by payload /// (e.g. the decompiled type's full name) to stay robust under parallel test runs. /// sealed class RecordingListener : EventListener { readonly ConcurrentQueue<(string EventName, Dictionary Payload)> events = new(); public RecordingListener(EventLevel level, EventKeywords keywords) { EnableEvents(DecompilerEventSource.Log, level, keywords); } protected override void OnEventWritten(EventWrittenEventArgs eventData) { var payload = new Dictionary(); if (eventData.PayloadNames != null && eventData.Payload != null) { for (int i = 0; i < eventData.PayloadNames.Count; i++) { payload[eventData.PayloadNames[i]] = eventData.Payload[i]; } } events.Enqueue((eventData.EventName ?? "", payload)); } public List> EventsNamed(string eventName) { return events.Where(e => e.EventName == eventName).Select(e => e.Payload).ToList(); } public List EventNames => events.Select(e => e.EventName).Distinct().ToList(); } /// Sample decompilation input covering all member kinds. class SampleDecompilationTarget { public int field; public int Property { get; set; } public event EventHandler? Event; public int Method(int x) { if (x > 0) { Event?.Invoke(this, EventArgs.Empty); return x + field; } return -x; } } static CSharpDecompiler CreateDecompilerForTestAssembly(out PEFile module) { module = new PEFile(typeof(DecompilerEventSourceTests).Assembly.Location); var resolver = new UniversalAssemblyResolver(module.FileName, false, module.Metadata.DetectTargetFrameworkId()); return new CSharpDecompiler(module, resolver, new DecompilerSettings()); } static void DecompileSampleTarget() { var decompiler = CreateDecompilerForTestAssembly(out var module); using (module) { decompiler.DecompileType(new FullTypeName(typeof(SampleDecompilationTarget).FullName)); } } [Test] public void ManifestIsValid() { string manifest = EventSource.GenerateManifest(typeof(DecompilerEventSource), typeof(DecompilerEventSource).Assembly.Location, EventManifestOptions.Strict); Assert.That(manifest, Is.Not.Null.And.Not.Empty); } [Test] public void FiringEveryEventProducesNoEventSourceErrors() { using var listener = new RecordingListener(EventLevel.Verbose, EventKeywords.All); var log = DecompilerEventSource.Log; log.DecompileTypeStart("T"); log.DecompileTypeStop("T"); log.DecompileMemberStart("T.M", 0x06000001, (int)DecompiledMemberKind.Method, 42); log.DecompileMemberStop("T.M", 0x06000001, (int)DecompiledMemberKind.Method); log.TypeSystemInitStart("module"); log.TypeSystemInitStop("module", 3); log.AssemblyResolveStart("System.Runtime"); log.AssemblyResolveStop("System.Runtime", "/path/System.Runtime.dll", true); log.ProjectDecompilationStart("module"); log.ProjectDecompilationStop("module", 10, 2); log.ProjectFileStart("File.cs", 5); log.ProjectFileStop("File.cs"); log.ILTransformExecuted("ILInlining", 0x06000001, 0.5); log.AstTransformExecuted("PatternStatementTransform", 1.5); // A mismatch between an [Event] method's signature and its WriteEvent call surfaces // as an "EventSourceMessage" error event on the same provider. Assert.That(listener.EventsNamed("EventSourceMessage"), Is.Empty); string[] expected = { "DecompileTypeStart", "DecompileTypeStop", "DecompileMemberStart", "DecompileMemberStop", "TypeSystemInitStart", "TypeSystemInitStop", "AssemblyResolveStart", "AssemblyResolveStop", "ProjectDecompilationStart", "ProjectDecompilationStop", "ProjectFileStart", "ProjectFileStop", "ILTransformExecuted", "AstTransformExecuted", }; foreach (string eventName in expected) { Assert.That(listener.EventsNamed(eventName), Has.Count.EqualTo(1), eventName); } } [Test] public void DecompilingTypeEmitsPairedStartStopEvents() { using var listener = new RecordingListener(EventLevel.Informational, DecompilerEventSource.Keywords.Decompilation); DecompileSampleTarget(); var typeStarts = listener.EventsNamed("DecompileTypeStart") .Where(p => ((string?)p["fullName"])?.Contains(nameof(SampleDecompilationTarget)) == true).ToList(); var typeStops = listener.EventsNamed("DecompileTypeStop") .Where(p => ((string?)p["fullName"])?.Contains(nameof(SampleDecompilationTarget)) == true).ToList(); Assert.That(typeStarts, Has.Count.EqualTo(1)); Assert.That(typeStops, Has.Count.EqualTo(1)); var memberStarts = listener.EventsNamed("DecompileMemberStart") .Where(p => ((string?)p["fullName"])?.Contains(nameof(SampleDecompilationTarget)) == true).ToList(); var memberStops = listener.EventsNamed("DecompileMemberStop") .Where(p => ((string?)p["fullName"])?.Contains(nameof(SampleDecompilationTarget)) == true).ToList(); Assert.That(memberStarts, Is.Not.Empty); Assert.That(memberStops, Has.Count.EqualTo(memberStarts.Count)); var kinds = memberStarts.Select(p => (int)p["memberKind"]!).Distinct().ToList(); Assert.That(kinds, Is.SubsetOf(new[] { (int)DecompiledMemberKind.Method, (int)DecompiledMemberKind.Field, (int)DecompiledMemberKind.Property, (int)DecompiledMemberKind.Event, })); // The sample type has at least one of each member kind. Assert.That(kinds, Has.Count.EqualTo(4)); Assert.That(memberStarts.Select(p => (int)p["metadataToken"]!), Has.All.Not.EqualTo(0)); var methodStarts = memberStarts.Where(p => (int)p["memberKind"]! == (int)DecompiledMemberKind.Method).ToList(); Assert.That(methodStarts.Select(p => (int)p["ilBodySize"]!), Has.Some.GreaterThan(0)); // Per-transform events require the Transforms keyword at Verbose level. Assert.That(listener.EventsNamed("ILTransformExecuted"), Is.Empty); Assert.That(listener.EventsNamed("AstTransformExecuted"), Is.Empty); } [Test] public void CreatingTypeSystemEmitsTypeSystemAndResolveEvents() { using var listener = new RecordingListener(EventLevel.Informational, DecompilerEventSource.Keywords.TypeSystem | DecompilerEventSource.Keywords.AssemblyResolver); using var module = new PEFile(typeof(DecompilerEventSourceTests).Assembly.Location); var resolver = new UniversalAssemblyResolver(module.FileName, false, module.Metadata.DetectTargetFrameworkId()); _ = new DecompilerTypeSystem(module, resolver); // Other fixtures may build type systems concurrently (events are process-wide), // so assert presence rather than exact global counts. var initStarts = listener.EventsNamed("TypeSystemInitStart") .Where(p => (string?)p["moduleName"] == module.Name).ToList(); var initStops = listener.EventsNamed("TypeSystemInitStop") .Where(p => (string?)p["moduleName"] == module.Name).ToList(); Assert.That(initStarts, Is.Not.Empty); Assert.That(initStops, Is.Not.Empty); Assert.That(initStops.Select(p => (int)p["referencedAssembliesResolved"]!), Has.Some.GreaterThan(0)); var resolveStarts = listener.EventsNamed("AssemblyResolveStart") .Where(p => ((string?)p["referenceName"])?.StartsWith("System.Runtime,", StringComparison.Ordinal) == true).ToList(); var resolveStops = listener.EventsNamed("AssemblyResolveStop") .Where(p => ((string?)p["referenceName"])?.StartsWith("System.Runtime,", StringComparison.Ordinal) == true).ToList(); Assert.That(resolveStarts, Is.Not.Empty); Assert.That(resolveStops, Is.Not.Empty); Assert.That(resolveStops.Where(p => (bool)p["success"]! && !string.IsNullOrEmpty((string?)p["resolvedPath"])), Is.Not.Empty, "System.Runtime must resolve to a file on disk"); } [Test] public void WholeProjectDecompilationEmitsProjectEvents() { string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); Directory.CreateDirectory(tempDir); try { string dllPath = Path.Combine(tempDir, "TraceTestAssembly.dll"); CompileTinyAssembly(dllPath); string projectDir = Path.Combine(tempDir, "project"); Directory.CreateDirectory(projectDir); using var listener = new RecordingListener(EventLevel.Informational, DecompilerEventSource.Keywords.ProjectDecompiler); using (var module = new PEFile(dllPath)) { var resolver = new UniversalAssemblyResolver(dllPath, false, module.Metadata.DetectTargetFrameworkId()); new WholeProjectDecompiler(resolver).DecompileProject(module, projectDir); } var runStarts = listener.EventsNamed("ProjectDecompilationStart") .Where(p => ((string?)p["moduleName"])?.Contains("TraceTestAssembly") == true).ToList(); var runStops = listener.EventsNamed("ProjectDecompilationStop") .Where(p => ((string?)p["moduleName"])?.Contains("TraceTestAssembly") == true).ToList(); Assert.That(runStarts, Has.Count.EqualTo(1)); Assert.That(runStops, Has.Count.EqualTo(1)); Assert.That((int)runStops[0]["codeFileCount"]!, Is.GreaterThanOrEqualTo(2)); string[] expectedFiles = { "TraceTestClassA.cs", "TraceTestClassB.cs" }; var fileStarts = listener.EventsNamed("ProjectFileStart") .Where(p => expectedFiles.Contains((string?)p["fileName"])).ToList(); var fileStops = listener.EventsNamed("ProjectFileStop") .Where(p => expectedFiles.Contains((string?)p["fileName"])).ToList(); Assert.That(fileStarts, Has.Count.EqualTo(2)); Assert.That(fileStops, Has.Count.EqualTo(2)); Assert.That(fileStarts.Select(p => (int)p["typeCount"]!), Has.All.EqualTo(1)); } finally { Directory.Delete(tempDir, recursive: true); } } static void CompileTinyAssembly(string dllPath) { const string source = @" public class TraceTestClassA { public int M(int x) { return x + 1; } } public class TraceTestClassB { public string N() { return ""b""; } }"; string runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; var compilation = CSharpCompilation.Create("TraceTestAssembly", new[] { CSharpSyntaxTree.ParseText(source) }, new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll")), }, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var result = compilation.Emit(dllPath); Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, result.Diagnostics.Select(d => d.ToString()))); } [Test] public void TransformsKeywordEmitsPerTransformEvents() { using var listener = new RecordingListener(EventLevel.Verbose, DecompilerEventSource.Keywords.Transforms); DecompileSampleTarget(); var ilTransforms = listener.EventsNamed("ILTransformExecuted"); Assert.That(ilTransforms, Is.Not.Empty); Assert.That(ilTransforms.Select(p => (string?)p["transformName"]), Has.All.Not.Empty); Assert.That(ilTransforms.Select(p => (string?)p["transformName"]), Has.Some.EqualTo("ILInlining")); Assert.That(ilTransforms.Select(p => (int)p["methodToken"]!), Has.All.Not.EqualTo(0)); Assert.That(ilTransforms.Select(p => (double)p["elapsedMs"]!), Has.All.GreaterThanOrEqualTo(0.0)); var astTransforms = listener.EventsNamed("AstTransformExecuted"); Assert.That(astTransforms, Is.Not.Empty); Assert.That(astTransforms.Select(p => (string?)p["transformName"]), Has.Some.EqualTo("PatternStatementTransform")); // The Transforms keyword alone must not enable the per-type/per-member events. Assert.That(listener.EventsNamed("DecompileTypeStart"), Is.Empty); Assert.That(listener.EventsNamed("DecompileMemberStart"), Is.Empty); } } }