File: CrashTelemetry_Tests.cs
Web Access
Project: ..\..\..\src\Framework.UnitTests\Microsoft.Build.Framework.UnitTests.csproj (Microsoft.Build.Framework.UnitTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Build.Framework.Telemetry;
using Shouldly;
using Xunit;
 
namespace Microsoft.Build.Framework.UnitTests;
 
public class CrashTelemetry_Tests
{
    [Fact]
    public void PopulateFromException_SetsAllFields()
    {
        var inner = new ArgumentException("inner");
        var outer = new InvalidOperationException("outer", inner);
 
        CrashTelemetry telemetry = new();
 
        // Throw and catch to get a real stack trace.
        try
        {
            throw outer;
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.ExceptionType.ShouldBe("System.InvalidOperationException");
        telemetry.InnerExceptionType.ShouldBe("System.ArgumentException");
        telemetry.HResult.ShouldNotBeNull();
        telemetry.StackHash.ShouldNotBeNull();
        telemetry.StackTop.ShouldNotBeNull();
    }
 
    [Fact]
    public void PopulateFromException_NoInnerException_SetsInnerToNull()
    {
        CrashTelemetry telemetry = new();
 
        try
        {
            throw new FileNotFoundException("not found");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.ExceptionType.ShouldBe("System.IO.FileNotFoundException");
        telemetry.InnerExceptionType.ShouldBeNull();
    }
 
    [Fact]
    public void StackHash_IsDeterministic()
    {
        CrashTelemetry t1 = new();
        CrashTelemetry t2 = new();
 
        try
        {
            throw new Exception("test");
        }
        catch (Exception ex)
        {
            t1.PopulateFromException(ex);
            t2.PopulateFromException(ex);
        }
 
        t1.StackHash.ShouldBe(t2.StackHash);
    }
 
    [Fact]
    public void StackTop_RedactsFilePaths()
    {
        CrashTelemetry telemetry = new();
 
        try
        {
            throw new Exception("test");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        // In debug builds, the stack trace includes file paths.
        // StackTop should have " in <redacted>:line " instead of the real path.
        string? stackTop = telemetry.StackTop;
        stackTop.ShouldNotBeNull();
        stackTop.ShouldNotContain(nameof(CrashTelemetry_Tests) + ".cs");
    }
 
    [Fact]
    public void GetProperties_IncludesAllSetFields()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.InvalidOperationException",
            InnerExceptionType = "System.ArgumentException",
            ExitType = CrashExitType.Unexpected,
            IsCritical = false,
            IsUnhandled = true,
            StackHash = "ABC123",
            StackTop = "at Foo.Bar()",
            HResult = -2147024809,
            BuildEngineVersion = "17.0.0",
            BuildEngineFrameworkName = ".NET 10.0",
            BuildEngineHost = "VS",
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props[nameof(CrashTelemetry.ExceptionType)].ShouldBe("System.InvalidOperationException");
        props[nameof(CrashTelemetry.InnerExceptionType)].ShouldBe("System.ArgumentException");
        props[nameof(CrashTelemetry.ExitType)].ShouldBe("Unexpected");
        props[nameof(CrashTelemetry.IsCritical)].ShouldBe("False");
        props[nameof(CrashTelemetry.IsUnhandled)].ShouldBe("True");
        props[nameof(CrashTelemetry.StackHash)].ShouldBe("ABC123");
        props[nameof(CrashTelemetry.StackTop)].ShouldBe("at Foo.Bar()");
        props[nameof(CrashTelemetry.HResult)].ShouldBe("-2147024809");
        props[nameof(CrashTelemetry.BuildEngineVersion)].ShouldBe("17.0.0");
        props[nameof(CrashTelemetry.BuildEngineFrameworkName)].ShouldBe(".NET 10.0");
        props[nameof(CrashTelemetry.BuildEngineHost)].ShouldBe("VS");
    }
 
    [Fact]
    public void GetProperties_OmitsNullFields()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.Exception",
            IsUnhandled = false,
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props.ShouldContainKey(nameof(CrashTelemetry.ExceptionType));
        props.ShouldContainKey(nameof(CrashTelemetry.IsUnhandled));
        props.ShouldNotContainKey(nameof(CrashTelemetry.InnerExceptionType));
        props.ShouldNotContainKey(nameof(CrashTelemetry.StackHash));
        props.ShouldNotContainKey(nameof(CrashTelemetry.BuildEngineHost));
    }
 
    [Fact]
    public void GetActivityProperties_IncludesAllSetFields()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.InvalidOperationException",
            ExitType = CrashExitType.Unexpected,
            IsCritical = true,
            IsUnhandled = false,
            StackHash = "DEF456",
            StackTop = "at Foo.Baz()",
            HResult = -1,
            BuildEngineVersion = "17.0.0",
            BuildEngineFrameworkName = ".NET 10.0",
            BuildEngineHost = "CLI",
        };
 
        Dictionary<string, object> props = telemetry.GetActivityProperties();
        props[nameof(CrashTelemetry.ExceptionType)].ShouldBe("System.InvalidOperationException");
        props[nameof(CrashTelemetry.ExitType)].ShouldBe("Unexpected");
        props[nameof(CrashTelemetry.IsCritical)].ShouldBe(true);
        props[nameof(CrashTelemetry.IsUnhandled)].ShouldBe(false);
        props[nameof(CrashTelemetry.StackHash)].ShouldBe("DEF456");
        props[nameof(CrashTelemetry.StackTop)].ShouldBe("at Foo.Baz()");
        props[nameof(CrashTelemetry.HResult)].ShouldBe(-1);
        props[nameof(CrashTelemetry.BuildEngineVersion)].ShouldBe("17.0.0");
        props[nameof(CrashTelemetry.BuildEngineFrameworkName)].ShouldBe(".NET 10.0");
        props[nameof(CrashTelemetry.BuildEngineHost)].ShouldBe("CLI");
    }
 
    [Fact]
    public void EventName_IsCrash()
    {
        CrashTelemetry telemetry = new();
        telemetry.EventName.ShouldBe("crash");
    }
 
    [Fact]
    public void PopulateFromException_SetsInnermostExceptionType_ForNestedExceptions()
    {
        var root = new IOException("root cause");
        var mid = new InvalidOperationException("mid", root);
        var outer = new TypeInitializationException("SomeType", mid);
 
        CrashTelemetry telemetry = new();
 
        try
        {
            throw outer;
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.ExceptionType.ShouldBe("System.TypeInitializationException");
        telemetry.InnerExceptionType.ShouldBe("System.InvalidOperationException");
        telemetry.InnermostExceptionType.ShouldBe("System.IO.IOException");
    }
 
    [Fact]
    public void PopulateFromException_InnermostExceptionType_IsNull_WhenNoInnerException()
    {
        CrashTelemetry telemetry = new();
 
        try
        {
            throw new ArgumentException("no inner");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.InnermostExceptionType.ShouldBeNull();
    }
 
    [Fact]
    public void PopulateFromException_InnermostExceptionType_EqualsInner_WhenSingleInner()
    {
        var inner = new ArgumentException("inner");
        var outer = new InvalidOperationException("outer", inner);
 
        CrashTelemetry telemetry = new();
 
        try
        {
            throw outer;
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        // When there's only one inner, InnermostExceptionType should equal InnerExceptionType.
        telemetry.InnerExceptionType.ShouldBe("System.ArgumentException");
        telemetry.InnermostExceptionType.ShouldBe("System.ArgumentException");
    }
 
    [Fact]
    public void PopulateFromException_SetsCrashOriginToMSBuild_WhenExceptionFromMSBuildCode()
    {
        CrashTelemetry telemetry = new();
 
        // Throw from this test, which lives in a Microsoft.Build.* namespace.
        try
        {
            throw new InvalidOperationException("test");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.CrashOrigin.ShouldBe(CrashOriginKind.MSBuild);
        telemetry.CrashOriginNamespace.ShouldNotBeNull();
        telemetry.CrashOriginNamespace.ShouldStartWith("Microsoft.Build");
    }
 
    [Theory]
    [InlineData("Microsoft.Build.BackEnd.SomeClass.Method", "Microsoft.Build.BackEnd")]
    [InlineData("Microsoft.Build.Evaluation.ProjectParser.Parse", "Microsoft.Build.Evaluation")]
    [InlineData("Microsoft.Build.Execution.BuildManager.Build", "Microsoft.Build.Execution")]
    [InlineData("Microsoft.VisualStudio.RemoteControl.RemoteControlClient.GetFileAsync", "Microsoft.VisualStudio.RemoteControl")]
    [InlineData("System.IO.File.ReadAllText", "System.IO")]
    [InlineData("Newtonsoft.Json.JsonConvert.DeserializeObject", "Newtonsoft.Json")]
    public void ExtractOriginNamespace_ExtractsCorrectNamespace(string qualifiedMethod, string expectedNamespace)
    {
        // Build a fake stack trace with the given qualified method.
        string fakeStack = $"   at {qualifiedMethod}(String arg)";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        string? result = CrashTelemetry.ExtractOriginNamespace(ex);
 
        result.ShouldBe(expectedNamespace);
    }
 
    [Theory]
    [InlineData("Microsoft.Build.BackEnd", "MSBuild")]
    [InlineData("Microsoft.Build.Evaluation", "MSBuild")]
    [InlineData("Microsoft.Build", "MSBuild")]
    [InlineData("Microsoft.VisualStudio.RemoteControl", "ThirdParty")]
    [InlineData("System.IO", "ThirdParty")]
    [InlineData("Newtonsoft.Json", "ThirdParty")]
    [InlineData(null, "Unknown")]
    [InlineData("", "Unknown")]
    public void ClassifyOrigin_ReturnsCorrectCategory(string? originNamespace, string expectedOrigin)
    {
        CrashOriginKind result = CrashTelemetry.ClassifyOrigin(originNamespace);
        result.ToString().ShouldBe(expectedOrigin);
    }
 
    [Fact]
    public void ExtractOriginNamespace_ReturnsNull_WhenNoStackTrace()
    {
        // An exception that was never thrown has no stack trace.
        Exception ex = new Exception("no stack");
        string? result = CrashTelemetry.ExtractOriginNamespace(ex);
        result.ShouldBeNull();
    }
 
    [Fact]
    public void GetProperties_IncludesNewFields()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.TypeInitializationException",
            IsUnhandled = true,
            CrashOrigin = CrashOriginKind.MSBuild,
            CrashOriginNamespace = "Microsoft.Build.BackEnd",
            InnermostExceptionType = "System.IO.IOException",
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props[nameof(CrashTelemetry.CrashOrigin)].ShouldBe("MSBuild");
        props[nameof(CrashTelemetry.CrashOriginNamespace)].ShouldBe("Microsoft.Build.BackEnd");
        props[nameof(CrashTelemetry.InnermostExceptionType)].ShouldBe("System.IO.IOException");
    }
 
    [Fact]
    public void GetActivityProperties_IncludesNewFields()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.OutOfMemoryException",
            IsUnhandled = true,
            CrashOrigin = CrashOriginKind.ThirdParty,
            CrashOriginNamespace = "Microsoft.VisualStudio.RemoteControl",
            InnermostExceptionType = "System.OutOfMemoryException",
        };
 
        Dictionary<string, object> props = telemetry.GetActivityProperties();
        props[nameof(CrashTelemetry.CrashOrigin)].ShouldBe("ThirdParty");
        props[nameof(CrashTelemetry.CrashOriginNamespace)].ShouldBe("Microsoft.VisualStudio.RemoteControl");
        props[nameof(CrashTelemetry.InnermostExceptionType)].ShouldBe("System.OutOfMemoryException");
    }
 
    [Fact]
    public void ExtractStackCaller_ReturnsCallerFrame_WhenTopIsThrowHelper()
    {
        string fakeStack =
            "   at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\r\n" +
            "   at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        string? caller = CrashTelemetry.ExtractStackCaller(ex);
 
        caller.ShouldNotBeNull();
        caller.ShouldContain("Microsoft.Build.BackEnd.RequestBuilder.BuildProject");
    }
 
    [Fact]
    public void ExtractStackCaller_ReturnsNull_WhenTopIsNotThrowHelper()
    {
        string fakeStack =
            "   at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)\r\n" +
            "   at Microsoft.Build.BackEnd.BuildManager.Build()";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        string? caller = CrashTelemetry.ExtractStackCaller(ex);
 
        caller.ShouldBeNull();
    }
 
    [Fact]
    public void ExtractStackCaller_ReturnsNull_WhenThrowHelperIsOnlyFrame()
    {
        string fakeStack = "   at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        string? caller = CrashTelemetry.ExtractStackCaller(ex);
 
        caller.ShouldBeNull();
    }
 
    [Fact]
    public void ExtractStackCaller_ReturnsNull_WhenNoStackTrace()
    {
        Exception ex = new Exception("no stack");
 
        string? caller = CrashTelemetry.ExtractStackCaller(ex);
 
        caller.ShouldBeNull();
    }
 
    [Theory]
    [InlineData("ErrorUtilities.VerifyThrowInternalError(")]
    [InlineData("ErrorUtilities.ThrowInternalErrorUnreachable(")]
    [InlineData("ErrorUtilities.VerifyThrowInternalNull(")]
    [InlineData("ErrorUtilities.ThrowInvalidOperation(")]
    [InlineData("ErrorUtilities.VerifyThrow(")]
    public void ExtractStackCaller_RecognizesAllThrowHelpers(string helperMethod)
    {
        string fakeStack =
            $"   at Microsoft.Build.Shared.{helperMethod}String message)\r\n" +
            "   at Microsoft.Build.Evaluation.Evaluator.Evaluate()";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        string? caller = CrashTelemetry.ExtractStackCaller(ex);
 
        caller.ShouldNotBeNull();
        caller.ShouldContain("Microsoft.Build.Evaluation.Evaluator.Evaluate");
    }
 
    [Fact]
    public void ExtractStackCaller_RedactsFilePaths_InCallerFrame()
    {
        string fakeStack =
            "   at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\r\n" +
            "   at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile) in C:\\Users\\username\\src\\file.cs:line 42";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        string? caller = CrashTelemetry.ExtractStackCaller(ex);
 
        caller.ShouldNotBeNull();
        caller.ShouldNotContain("username");
        caller.ShouldContain("<redacted>");
        caller.ShouldContain(":line 42");
    }
 
    [Fact]
    public void PopulateFromException_SetsStackCaller_WhenThrowHelperIsOnTop()
    {
        CrashTelemetry telemetry = new();
 
        // Simulate a throw-helper scenario using a fake stack trace.
        string fakeStack =
            "   at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\r\n" +
            "   at Microsoft.Build.Scheduler.ScheduleRequest(BuildRequest request)";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        telemetry.PopulateFromException(ex);
 
        telemetry.StackTop.ShouldNotBeNull();
        telemetry.StackTop!.ShouldContain("ErrorUtilities.ThrowInternalError");
        telemetry.StackCaller.ShouldNotBeNull();
        telemetry.StackCaller!.ShouldContain("Microsoft.Build.Scheduler.ScheduleRequest");
    }
 
    [Fact]
    public void PopulateFromException_SetsExceptionMessage()
    {
        CrashTelemetry telemetry = new();
 
        try
        {
            throw new InvalidOperationException("something went wrong");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.ExceptionMessage.ShouldBe("something went wrong");
    }
 
    [Fact]
    public void PopulateFromException_StripsInternalErrorPrefix()
    {
        CrashTelemetry telemetry = new();
 
        try
        {
            throw new Exception("MSB0001: Internal MSBuild Error: All submissions not yet complete.");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        telemetry.ExceptionMessage.ShouldBe("All submissions not yet complete.");
    }
 
    [Fact]
    public void TruncateMessage_ReturnsNull_WhenEmpty()
    {
        CrashTelemetry.TruncateMessage(null).ShouldBeNull();
        CrashTelemetry.TruncateMessage("").ShouldBeNull();
    }
 
    [Fact]
    public void TruncateMessage_TruncatesLongMessages()
    {
        string longMessage = new string('x', 500);
        string? result = CrashTelemetry.TruncateMessage(longMessage);
 
        result.ShouldNotBeNull();
        result.Length.ShouldBe(256);
    }
 
    [Fact]
    public void TruncateMessage_RedactsWindowsPaths()
    {
        string message = @"C:\Users\useralias\src\project.csproj unexpectedly not a rooted path";
        string? result = CrashTelemetry.TruncateMessage(message);
 
        result.ShouldNotBeNull();
        result.ShouldNotContain("useralias");
        result.ShouldNotContain(@"C:\Users");
        result.ShouldContain("<path>");
        result.ShouldContain("unexpectedly not a rooted path");
    }
 
    [Fact]
    public void TruncateMessage_RedactsUnixPaths()
    {
        string message = @"/home/useralias/src/project.csproj unexpectedly not a rooted path";
        string? result = CrashTelemetry.TruncateMessage(message);
 
        result.ShouldNotBeNull();
        result.ShouldNotContain("useralias");
        result.ShouldContain("<path>");
    }
 
    [Fact]
    public void TruncateMessage_PreservesNonPathMessages()
    {
        string message = "All submissions not yet complete.";
        string? result = CrashTelemetry.TruncateMessage(message);
        result.ShouldBe("All submissions not yet complete.");
    }
 
    [Fact]
    public void PopulateFromException_SetsCrashThreadName()
    {
        CrashTelemetry telemetry = new();
 
        try
        {
            throw new Exception("test");
        }
        catch (Exception ex)
        {
            telemetry.PopulateFromException(ex);
        }
 
        // The thread name may be null in test harness but the property should be set (even if null).
        // Just verify no exception was thrown during population.
        // In a named-thread scenario, it would capture the name.
    }
 
    [Fact]
    public void GetProperties_IncludesStackCaller_WhenSet()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "Microsoft.Build.Framework.InternalErrorException",
            IsUnhandled = true,
            StackTop = "at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)",
            StackCaller = "at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)",
            ExceptionMessage = "All submissions not yet complete.",
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props[nameof(CrashTelemetry.StackCaller)].ShouldBe("at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)");
        props[nameof(CrashTelemetry.ExceptionMessage)].ShouldBe("All submissions not yet complete.");
    }
 
    [Fact]
    public void GetProperties_OmitsStackCaller_WhenNull()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.NullReferenceException",
            IsUnhandled = true,
            StackTop = "at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)",
            StackCaller = null,
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props.ShouldNotContainKey(nameof(CrashTelemetry.StackCaller));
    }
 
    [Fact]
    public void GetActivityProperties_IncludesStackCaller_WhenSet()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "Microsoft.Build.Framework.InternalErrorException",
            IsUnhandled = true,
            StackTop = "at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)",
            StackCaller = "at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)",
        };
 
        Dictionary<string, object> props = telemetry.GetActivityProperties();
        props[nameof(CrashTelemetry.StackCaller)].ShouldBe("at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile)");
    }
 
    [Fact]
    public void PopulateFromException_SetsFullStackTrace()
    {
        string fakeStack =
            "   at Microsoft.Build.Shared.ErrorUtilities.ThrowInternalError(String message, Object[] args)\n" +
            "   at Microsoft.Build.BackEnd.RequestBuilder.BuildProject(String projectFile) in C:\\Users\\user\\src\\file.cs:line 42\n" +
            "   at Microsoft.Build.BackEnd.BuildManager.Build() in C:\\Users\\user\\src\\mgr.cs:line 100";
        Exception ex = CreateExceptionWithStack(fakeStack);
 
        CrashTelemetry telemetry = new();
        telemetry.PopulateFromException(ex);
 
        telemetry.FullStackTrace.ShouldNotBeNull();
        // Should contain all frames
        telemetry.FullStackTrace!.ShouldContain("ErrorUtilities.ThrowInternalError");
        telemetry.FullStackTrace.ShouldContain("RequestBuilder.BuildProject");
        telemetry.FullStackTrace.ShouldContain("BuildManager.Build");
        // File paths should be redacted
        telemetry.FullStackTrace.ShouldNotContain("C:\\Users\\user");
        telemetry.FullStackTrace.ShouldContain("in <redacted>:line 42");
    }
 
    [Fact]
    public void ExtractFullStackTrace_ReturnsNull_WhenNoStackTrace()
    {
        Exception ex = CreateExceptionWithStack(null!);
        CrashTelemetry.ExtractFullStackTrace(ex).ShouldBeNull();
    }
 
    [Fact]
    public void ExtractFullStackTrace_TruncatesLongStackTraces()
    {
        // Build a stack trace longer than MaxStackTraceLength
        var sb = new System.Text.StringBuilder();
        for (int i = 0; i < 200; i++)
        {
            sb.AppendLine($"   at Namespace.Type.Method{i}()");
        }
        Exception ex = CreateExceptionWithStack(sb.ToString());
 
        string? result = CrashTelemetry.ExtractFullStackTrace(ex);
        result.ShouldNotBeNull();
        result!.Length.ShouldBeLessThanOrEqualTo(CrashTelemetry.MaxStackTraceLength);
        result.ShouldEndWith("... [truncated]");
    }
 
    [Fact]
    public void GetProperties_IncludesFullStackTrace_WhenSet()
    {
        CrashTelemetry telemetry = new()
        {
            ExceptionType = "System.Exception",
            FullStackTrace = "   at Foo.Bar()\n   at Baz.Qux()",
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props[nameof(CrashTelemetry.FullStackTrace)].ShouldBe("   at Foo.Bar()\n   at Baz.Qux()");
    }
 
    [Fact]
    public void SanitizeFilePathsInText_RedactsPathsInStackFrames()
    {
        string input = "   at Foo.Bar() in C:\\Users\\secret\\src\\file.cs:line 99";
        string result = CrashTelemetry.SanitizeFilePathsInText(input);
        result.ShouldNotContain("secret");
        result.ShouldContain("in <redacted>:line 99");
    }
 
    [Fact]
    public void SanitizeFilePathsInText_LeavesNonPathLinesUnchanged()
    {
        string input = "System.Exception: something broke\n   at Foo.Bar()";
        string result = CrashTelemetry.SanitizeFilePathsInText(input);
        result.ShouldBe(input);
    }
 
    [Fact]
    public void EndBuildHang_GetProperties_IncludesHangDiagnostics()
    {
        CrashTelemetry telemetry = new()
        {
            ExitType = CrashExitType.EndBuildHang,
            EndBuildWaitPhase = "WaitingForSubmissions",
            EndBuildWaitDurationMs = 60000,
            PendingSubmissionCount = 3,
            SubmissionsWithResultNoLogging = 1,
            ThreadExceptionRecorded = false,
            UnmatchedProjectStartedCount = 2,
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props[nameof(CrashTelemetry.ExitType)].ShouldBe("EndBuildHang");
        props[nameof(CrashTelemetry.EndBuildWaitPhase)].ShouldBe("WaitingForSubmissions");
        props[nameof(CrashTelemetry.EndBuildWaitDurationMs)].ShouldBe("60000");
        props[nameof(CrashTelemetry.PendingSubmissionCount)].ShouldBe("3");
        props[nameof(CrashTelemetry.SubmissionsWithResultNoLogging)].ShouldBe("1");
        props[nameof(CrashTelemetry.ThreadExceptionRecorded)].ShouldBe("False");
        props[nameof(CrashTelemetry.UnmatchedProjectStartedCount)].ShouldBe("2");
    }
 
    [Fact]
    public void EndBuildHang_GetActivityProperties_IncludesHangDiagnostics()
    {
        CrashTelemetry telemetry = new()
        {
            ExitType = CrashExitType.EndBuildHang,
            EndBuildWaitPhase = "WaitingForNodes",
            EndBuildWaitDurationMs = 30000,
            PendingSubmissionCount = 0,
            SubmissionsWithResultNoLogging = 0,
            ThreadExceptionRecorded = true,
            UnmatchedProjectStartedCount = 0,
        };
 
        Dictionary<string, object> props = telemetry.GetActivityProperties();
        props[nameof(CrashTelemetry.EndBuildWaitPhase)].ShouldBe("WaitingForNodes");
        props[nameof(CrashTelemetry.EndBuildWaitDurationMs)].ShouldBe(30000L);
        props[nameof(CrashTelemetry.PendingSubmissionCount)].ShouldBe(0);
        props[nameof(CrashTelemetry.SubmissionsWithResultNoLogging)].ShouldBe(0);
        props[nameof(CrashTelemetry.ThreadExceptionRecorded)].ShouldBe(true);
        props[nameof(CrashTelemetry.UnmatchedProjectStartedCount)].ShouldBe(0);
    }
 
    [Fact]
    public void EndBuildHang_GetProperties_OmitsNullHangProperties()
    {
        CrashTelemetry telemetry = new()
        {
            ExitType = CrashExitType.EndBuildHang,
            EndBuildWaitPhase = "WaitingForSubmissions",
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props.ShouldContainKey(nameof(CrashTelemetry.EndBuildWaitPhase));
        props.ShouldNotContainKey(nameof(CrashTelemetry.PendingSubmissionCount));
        props.ShouldNotContainKey(nameof(CrashTelemetry.ThreadExceptionRecorded));
    }
 
    [Fact]
    public void EndBuildHang_DroppedProperties_NotPresent()
    {
        // Verify that the dropped properties from the critical evaluation
        // (ActiveNodeCount, SubmissionsWithNoResult, CancellationRequested,
        //  ShuttingDown, SchedulerHitNoLoggingCompleted, SchedulerNoLoggingDetails)
        // do not appear in the telemetry output.
        CrashTelemetry telemetry = new()
        {
            ExitType = CrashExitType.EndBuildHang,
            EndBuildWaitPhase = "WaitingForSubmissions",
            EndBuildWaitDurationMs = 30000,
            PendingSubmissionCount = 1,
        };
 
        IDictionary<string, string> props = telemetry.GetProperties();
        props.ShouldNotContainKey("ActiveNodeCount");
        props.ShouldNotContainKey("SubmissionsWithNoResult");
        props.ShouldNotContainKey("CancellationRequested");
        props.ShouldNotContainKey("ShuttingDown");
        props.ShouldNotContainKey("SchedulerHitNoLoggingCompleted");
        props.ShouldNotContainKey("SchedulerNoLoggingDetails");
    }
 
    /// <summary>
    /// Creates an exception whose StackTrace property returns the given fake stack string.
    /// </summary>
    private static Exception CreateExceptionWithStack(string fakeStack)
    {
        return new ExceptionWithFakeStack(fakeStack);
    }
 
    [Fact]
    public void TruncateMessage_RedactsUncPaths()
    {
        string message = @"Could not access \\server\share\builds\project.sln during build";
        string? result = CrashTelemetry.TruncateMessage(message);
 
        result.ShouldNotBeNull();
        result.ShouldNotContain("server");
        result.ShouldNotContain("share");
        result.ShouldContain("<path>");
    }
 
    [Fact]
    public void TruncateMessage_RedactsPathsWithSpaces()
    {
        // Paths containing spaces are partially redacted — each non-space segment
        // after a recognized root prefix is replaced. The PII-relevant parts
        // (username, project name) are in non-space segments and will be redacted.
        string message = @"Could not find C:\Users\useralias\my-project\bin\app.exe";
        string? result = CrashTelemetry.TruncateMessage(message);
 
        result.ShouldNotBeNull();
        result.ShouldNotContain("useralias");
        result.ShouldNotContain("my-project");
        result.ShouldContain("<path>");
    }
 
    [Fact]
    public void SanitizeFilePathsInText_RedactsPathsInNonStackFrameLines()
    {
        string input = "System.IO.FileNotFoundException: Could not find C:\\Users\\useralias\\project.csproj";
        string result = CrashTelemetry.SanitizeFilePathsInText(input);
        result.ShouldNotContain("useralias");
        result.ShouldContain("<path>");
    }
 
    [Fact]
    public void SanitizeFilePathsInText_RedactsUncPathsInNonStackFrameLines()
    {
        string input = "Failed to load assembly from \\\\server\\share\\assembly.dll";
        string result = CrashTelemetry.SanitizeFilePathsInText(input);
        result.ShouldNotContain("server");
        result.ShouldContain("<path>");
    }
 
    private sealed class ExceptionWithFakeStack : Exception
    {
        private readonly string _stack;
 
        public ExceptionWithFakeStack(string stack) : base("fake")
        {
            _stack = stack;
        }
 
        public override string? StackTrace => _stack;
    }
}