File: EditAndContinue\EditAndContinueTestVerifier.cs
Web Access
Project: src\src\Features\TestUtilities\Microsoft.CodeAnalysis.Features.Test.Utilities.csproj (Microsoft.CodeAnalysis.Features.Test.Utilities)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.Differencing;
using Microsoft.CodeAnalysis.Contracts.EditAndContinue;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Test.Utilities;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
using Xunit;
using static Microsoft.CodeAnalysis.EditAndContinue.AbstractEditAndContinueAnalyzer;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Host;
 
namespace Microsoft.CodeAnalysis.EditAndContinue.UnitTests;
 
internal abstract class EditAndContinueTestVerifier
{
    public const EditAndContinueCapabilities BaselineCapabilities = EditAndContinueCapabilities.Baseline;
 
    public const EditAndContinueCapabilities Net5RuntimeCapabilities =
        EditAndContinueCapabilities.Baseline |
        EditAndContinueCapabilities.AddInstanceFieldToExistingType |
        EditAndContinueCapabilities.AddStaticFieldToExistingType |
        EditAndContinueCapabilities.AddMethodToExistingType |
        EditAndContinueCapabilities.NewTypeDefinition |
        EditAndContinueCapabilities.AddExplicitInterfaceImplementation;
 
    public const EditAndContinueCapabilities Net6RuntimeCapabilities =
        Net5RuntimeCapabilities |
        EditAndContinueCapabilities.ChangeCustomAttributes |
        EditAndContinueCapabilities.UpdateParameters;
 
    public const EditAndContinueCapabilities AllRuntimeCapabilities =
        Net6RuntimeCapabilities |
        EditAndContinueCapabilities.GenericAddMethodToExistingType |
        EditAndContinueCapabilities.GenericUpdateMethod |
        EditAndContinueCapabilities.GenericAddFieldToExistingType;
 
    public AbstractEditAndContinueAnalyzer Analyzer { get; }
 
    protected EditAndContinueTestVerifier(Action<SyntaxNode>? faultInjector)
    {
        Analyzer = CreateAnalyzer(faultInjector, LanguageName);
    }
 
    public abstract ImmutableArray<SyntaxNode> GetDeclarators(ISymbol method);
    public abstract string LanguageName { get; }
    public abstract string ProjectFileExtension { get; }
    public abstract TreeComparer<SyntaxNode> TopSyntaxComparer { get; }
    public abstract string? TryGetResource(string keyword);
 
    internal static AbstractEditAndContinueAnalyzer CreateAnalyzer(Action<SyntaxNode>? faultInjector, string languageName)
    {
        var exportProvider = FeaturesTestCompositions.Features.ExportProviderFactory.CreateExportProvider();
 
        var analyzer = (AbstractEditAndContinueAnalyzer)exportProvider
            .GetExports<ILanguageService, LanguageServiceMetadata>()
            .Single(e => e.Metadata.Language == languageName && e.Metadata.ServiceType == typeof(IEditAndContinueAnalyzer).AssemblyQualifiedName)
            .Value;
 
        analyzer.GetTestAccessor().FaultInjector = faultInjector;
        return analyzer;
    }
 
    private void VerifyDocumentActiveStatementsAndExceptionRegions(
        ActiveStatementsDescription description,
        SyntaxTree oldTree,
        SyntaxTree newTree,
        ImmutableArray<ActiveStatement> actualNewActiveStatements,
        ImmutableArray<ImmutableArray<SourceFileSpan>> actualNewExceptionRegions)
    {
        // check active statements:
        AssertSpansEqual(description.NewMappedSpans, actualNewActiveStatements.OrderBy(x => x.Id.Ordinal).Select(s => s.FileSpan), newTree);
 
        var oldRoot = oldTree.GetRoot();
 
        // check old exception regions:
        foreach (var oldStatement in description.OldStatements)
        {
            var oldRegions = Analyzer.GetExceptionRegions(
                oldRoot,
                oldStatement.UnmappedSpan,
                isNonLeaf: oldStatement.Statement.IsNonLeaf,
                CancellationToken.None);
 
            AssertSpansEqual(oldStatement.ExceptionRegions.Spans, oldRegions.Spans, oldTree);
        }
 
        // check new exception regions:
        if (!actualNewExceptionRegions.IsDefault)
        {
            Assert.Equal(actualNewActiveStatements.Length, actualNewExceptionRegions.Length);
            Assert.Equal(description.NewMappedRegions.Length, actualNewExceptionRegions.Length);
            for (var i = 0; i < actualNewActiveStatements.Length; i++)
            {
                var activeStatement = actualNewActiveStatements[i];
                AssertSpansEqual(description.NewMappedRegions[activeStatement.Id.Ordinal], actualNewExceptionRegions[i], newTree);
            }
        }
    }
 
    internal void VerifyLineEdits(
        EditScriptDescription editScript,
        SequencePointUpdates[] expectedLineEdits,
        SemanticEditDescription[]? expectedSemanticEdits,
        RudeEditDiagnosticDescription[]? expectedDiagnostics,
        EditAndContinueCapabilities? capabilities)
    {
        VerifySemantics(
            [editScript],
            TargetFramework.NetStandard20,
            [new DocumentAnalysisResultsDescription(semanticEdits: expectedSemanticEdits, lineEdits: expectedLineEdits, diagnostics: expectedDiagnostics)],
            capabilities);
    }
 
    internal void VerifySemantics(
        EditScriptDescription[] editScripts,
        TargetFramework targetFramework,
        DocumentAnalysisResultsDescription[] expectedResults,
        EditAndContinueCapabilities? capabilities = null)
    {
        Assert.True(editScripts.Length == expectedResults.Length);
        var documentCount = expectedResults.Length;
 
        using var workspace = new AdhocWorkspace(FeaturesTestCompositions.Features.GetHostServices());
        CreateProjects(editScripts, workspace, targetFramework, out var oldProject, out var newProject);
 
        var oldDocuments = oldProject.Documents.ToArray();
        var newDocuments = newProject.Documents.ToArray();
 
        Debug.Assert(oldDocuments.Length == newDocuments.Length);
 
        var oldTrees = oldDocuments.Select(d => d.GetSyntaxTreeSynchronously(default)!).ToArray();
        var newTrees = newDocuments.Select(d => d.GetSyntaxTreeSynchronously(default)!).ToArray();
 
        var testAccessor = Analyzer.GetTestAccessor();
        var allEdits = new List<SemanticEditInfo>();
 
        // include Baseline by default, unless no capabilities are explicitly specified:
        var requiredCapabilities = capabilities.HasValue
            ? (capabilities.Value == 0 ? 0 : capabilities.Value | EditAndContinueCapabilities.Baseline)
            : expectedResults.Any(r => r.Diagnostics.Any()) ? AllRuntimeCapabilities : EditAndContinueCapabilities.Baseline;
 
        var lazyCapabilities = AsyncLazy.Create(requiredCapabilities);
        var actualRequiredCapabilities = EditAndContinueCapabilities.None;
        var hasValidChanges = false;
        var log = new TraceLog("Test");
 
        for (var documentIndex = 0; documentIndex < documentCount; documentIndex++)
        {
            var assertMessagePrefix = (documentCount > 0) ? $"Document #{documentIndex}" : null;
 
            var expectedResult = expectedResults[documentIndex];
 
            var includeFirstLineInDiagnostics = expectedResult.Diagnostics.Any(d => d.FirstLine != null) == true;
            var newActiveStatementSpans = expectedResult.ActiveStatements.OldUnmappedTrackingSpans;
 
            // we need to rebuild the edit script, so that it operates on nodes associated with the same syntax trees backing the documents:
            var oldTree = oldTrees[documentIndex];
            var newTree = newTrees[documentIndex];
            var oldRoot = oldTree.GetRoot();
            var newRoot = newTree.GetRoot();
 
            var oldDocument = oldDocuments[documentIndex];
            var newDocument = newDocuments[documentIndex];
 
            var oldModel = oldDocument.GetSemanticModelAsync().Result;
            var newModel = newDocument.GetSemanticModelAsync().Result;
            Contract.ThrowIfNull(oldModel);
            Contract.ThrowIfNull(newModel);
 
            var lazyOldActiveStatementMap = AsyncLazy.Create(expectedResult.ActiveStatements.OldStatementsMap);
            var result = Analyzer.AnalyzeDocumentAsync(oldProject, lazyOldActiveStatementMap, newDocument, newActiveStatementSpans, lazyCapabilities, log, CancellationToken.None).Result;
            var oldText = oldDocument.GetTextSynchronously(default);
            var newText = newDocument.GetTextSynchronously(default);
 
            actualRequiredCapabilities |= result.RequiredCapabilities;
            hasValidChanges &= result.HasSignificantValidChanges;
 
            VerifyDiagnostics(expectedResult.Diagnostics, result.RudeEdits.ToDescription(newText, includeFirstLineInDiagnostics), assertMessagePrefix);
 
            if (!expectedResult.SemanticEdits.IsDefault)
            {
                if (result.HasChanges)
                {
                    VerifySemanticEdits(expectedResult.SemanticEdits, result.SemanticEdits, oldModel.Compilation, newModel.Compilation, oldRoot, newRoot, assertMessagePrefix);
 
                    allEdits.AddRange(result.SemanticEdits);
                }
                else
                {
                    Assert.True(expectedResult.SemanticEdits.IsEmpty);
                    Assert.True(result.SemanticEdits.IsDefault);
                }
            }
 
            if (!result.HasChanges)
            {
                Assert.True(result.ExceptionRegions.IsDefault);
                Assert.True(result.ActiveStatements.IsDefault);
                Assert.Equal(EditAndContinueCapabilities.None, result.RequiredCapabilities);
            }
            else
            {
                // exception regions not available in presence of rude edits:
                Assert.Equal(expectedResult.Diagnostics.Any(d => d.RudeEditKind.IsBlocking()), result.ExceptionRegions.IsDefault);
 
                VerifyDocumentActiveStatementsAndExceptionRegions(
                    expectedResult.ActiveStatements,
                    oldTree,
                    newTree,
                    result.ActiveStatements,
                    result.ExceptionRegions);
            }
 
            if (result.HasBlockingRudeEdits)
            {
                Assert.True(result.LineEdits.IsDefault);
                Assert.True(expectedResult.LineEdits.IsDefaultOrEmpty);
                Assert.Equal(EditAndContinueCapabilities.None, result.RequiredCapabilities);
            }
            else if (!expectedResult.LineEdits.IsDefault)
            {
                // check files of line edits:
                AssertEx.Equal(
                    expectedResult.LineEdits.Select(e => e.FileName),
                    result.LineEdits.Select(e => e.FileName),
                    itemSeparator: ",\r\n",
                    message: "File names of line edits differ in " + assertMessagePrefix);
 
                // check lines of line edits:
                _ = expectedResult.LineEdits.Zip(result.LineEdits, (expected, actual) =>
                {
                    AssertEx.Equal(
                        expected.LineUpdates,
                        actual.LineUpdates,
                        itemSeparator: ",\r\n",
                        itemInspector: s => $"new({s.OldLine}, {s.NewLine})",
                        message: "Line deltas differ in " + assertMessagePrefix);
 
                    return true;
                }).ToArray();
            }
        }
 
        if (hasValidChanges)
        {
            Assert.Equal(requiredCapabilities, actualRequiredCapabilities);
        }
 
        var duplicateNonPartial = allEdits
            .Where(e => e.PartialType == null && e.DeletedSymbolContainer is null)
            .GroupBy(e => e.Symbol, SymbolKey.GetComparer(ignoreCase: false, ignoreAssemblyKeys: true))
            .Where(g => g.Count() > 1)
            .Select(g => g.Key);
 
        AssertEx.Empty(duplicateNonPartial, "Duplicate non-partial symbols");
 
        // check if we can merge edits without throwing:
        EditSession.MergePartialEdits(oldProject.GetCompilationAsync().Result!, newProject.GetCompilationAsync().Result!, allEdits, out var mergedEdits, out _, CancellationToken.None);
 
        // merging is where we fill in NewSymbol for deletes, so make sure that happened too
        foreach (var edit in mergedEdits)
        {
            if (edit.Kind is SemanticEditKind.Delete &&
                edit.OldSymbol is IMethodSymbol)
            {
                Assert.True(edit.NewSymbol is not null);
            }
        }
    }
 
    public void VerifyDiagnostics(IEnumerable<RudeEditDiagnosticDescription> expected, IEnumerable<RudeEditDiagnosticDescription> actual, string? message = null)
    {
        // Assert that the diagnostics are actually what the test expects
        AssertEx.SetEqual(expected, actual, message: message, itemSeparator: ",\r\n", itemInspector: d => d.ToString(TryGetResource));
 
        // Also make sure to realise each diagnostic to ensure its message is able to be formatted
        foreach (var diagnostic in actual)
        {
            diagnostic.VerifyMessageFormat();
        }
    }
 
    private static void VerifySemanticEdits(
        ImmutableArray<SemanticEditDescription> expectedSemanticEdits,
        ImmutableArray<SemanticEditInfo> actualSemanticEdits,
        Compilation oldCompilation,
        Compilation newCompilation,
        SyntaxNode oldRoot,
        SyntaxNode newRoot,
        string? message = null)
    {
        // sort expected and actual edits to ignore differences in order, which are insignificant:
        expectedSemanticEdits = expectedSemanticEdits.Sort((x, y) => CompareEdits(CreateSymbolKey(x), x.Kind, CreateSymbolKey(y), y.Kind));
        actualSemanticEdits = actualSemanticEdits.Sort((x, y) => CompareEdits(x.Symbol, x.Kind, y.Symbol, y.Kind));
 
        static int CompareEdits(SymbolKey leftKey, SemanticEditKind leftKind, SymbolKey rightKey, SemanticEditKind rightKind)
            => leftKey.ToString().CompareTo(rightKey.ToString()) is not 0 and var result ? result : leftKind.CompareTo(rightKind);
 
        SymbolKey CreateSymbolKey(SemanticEditDescription edit)
            => SymbolKey.Create(edit.SymbolProvider((edit.Kind == SemanticEditKind.Delete) ? oldCompilation : newCompilation));
 
        // string comparison to simplify understanding why a test failed:
        AssertEx.Equal(
            expectedSemanticEdits.Select(e => $"{e.Kind}: {e.SymbolProvider((e.Kind == SemanticEditKind.Delete ? oldCompilation : newCompilation))}"),
            actualSemanticEdits.Select(e => $"{e.Kind}: {e.Symbol.Resolve(e.Kind == SemanticEditKind.Delete ? oldCompilation : newCompilation).Symbol}"),
            message: message);
 
        for (var i = 0; i < actualSemanticEdits.Length; i++)
        {
            var expectedSemanticEdit = expectedSemanticEdits[i];
            var actualSemanticEdit = actualSemanticEdits[i];
            var editKind = expectedSemanticEdit.Kind;
 
            Assert.Equal(editKind, actualSemanticEdit.Kind);
 
            var symbolKey = actualSemanticEdit.Symbol;
            ISymbol? expectedOldSymbol = null, expectedNewSymbol = null;
 
            switch (editKind)
            {
                case SemanticEditKind.Update:
                    expectedOldSymbol = expectedSemanticEdit.SymbolProvider(oldCompilation);
                    expectedNewSymbol = expectedSemanticEdit.SymbolProvider(newCompilation);
 
                    Assert.Equal(expectedOldSymbol, symbolKey.Resolve(oldCompilation, ignoreAssemblyKey: true).Symbol);
                    Assert.Equal(expectedNewSymbol, symbolKey.Resolve(newCompilation, ignoreAssemblyKey: true).Symbol);
                    break;
 
                case SemanticEditKind.Delete:
                    expectedOldSymbol = expectedSemanticEdit.SymbolProvider(oldCompilation);
 
                    // Symbol key will happily resolve to a definition part that has no implementation, so we validate that
                    // differently
                    if (expectedOldSymbol.IsPartialDefinition() &&
                        symbolKey.Resolve(oldCompilation, ignoreAssemblyKey: true).Symbol is ISymbol resolvedSymbol)
                    {
                        Assert.Equal(expectedOldSymbol, resolvedSymbol.PartialDefinitionPart());
                        Assert.Equal(null, resolvedSymbol.PartialImplementationPart());
                    }
                    else
                    {
                        Assert.Equal(expectedOldSymbol, symbolKey.Resolve(oldCompilation, ignoreAssemblyKey: true).Symbol);
 
                        // When we're deleting a symbol, and have a deleted symbol container, it means the symbol wasn't really deleted,
                        // but rather had its signature changed in some way. Some of those ways, like changing the return type, are not
                        // represented in the symbol key, so the check below would fail, so we skip it.
                        if (expectedSemanticEdit.DeletedSymbolContainerProvider is null)
                        {
                            Assert.Equal(null, symbolKey.Resolve(newCompilation, ignoreAssemblyKey: true).Symbol);
                        }
                    }
 
                    var deletedSymbolContainer = actualSemanticEdit.DeletedSymbolContainer?.Resolve(newCompilation, ignoreAssemblyKey: true).Symbol;
                    AssertEx.AreEqual(
                        deletedSymbolContainer,
                        expectedSemanticEdit.DeletedSymbolContainerProvider?.Invoke(newCompilation),
                        message: $"{message}, {editKind}({expectedNewSymbol ?? expectedOldSymbol}): Incorrect deleted container");
 
                    break;
 
                case SemanticEditKind.Insert or SemanticEditKind.Replace:
                    expectedNewSymbol = expectedSemanticEdit.SymbolProvider(newCompilation);
                    Assert.Equal(expectedNewSymbol, symbolKey.Resolve(newCompilation, ignoreAssemblyKey: true).Symbol);
                    break;
 
                default:
                    throw ExceptionUtilities.UnexpectedValue(editKind);
            }
 
            // Partial types must match:
            AssertEx.AreEqual(
                expectedSemanticEdit.PartialType?.Invoke(newCompilation),
                actualSemanticEdit.PartialType?.Resolve(newCompilation, ignoreAssemblyKey: true).Symbol,
                message: $"{message}, {editKind}({expectedNewSymbol ?? expectedOldSymbol}): Partial types do not match");
 
            var expectedSyntaxMap = expectedSemanticEdit.GetSyntaxMap();
 
            // Edit is expected to have a syntax map:
            var actualSyntaxMaps = actualSemanticEdit.SyntaxMaps;
            AssertEx.AreEqual(
                expectedSyntaxMap != null,
                actualSyntaxMaps.HasMap,
                message: $"{message}, {editKind}({expectedNewSymbol ?? expectedOldSymbol}): Incorrect syntax map");
 
            // If expected map is specified validate its mappings with the actual one:
            if (expectedSyntaxMap != null)
            {
                VerifySyntaxMaps(oldRoot, newRoot, expectedSyntaxMap, actualSyntaxMaps);
            }
        }
    }
 
    public static SyntaxNode FindNode(SyntaxNode root, TextSpan span)
    {
        var result = root.FindToken(span.Start).Parent!;
        while (result != null)
        {
            if (result.Span == span)
            {
                return result;
            }
 
            result = result.Parent!;
        }
 
        throw new Exception($"Unable to find node with span {span} `{root.GetText().GetSubText(span)}` in:{Environment.NewLine}{root}");
    }
 
    private static void VerifySyntaxMaps(
        SyntaxNode oldRoot,
        SyntaxNode newRoot,
        IEnumerable<(TextSpan oldSpan, TextSpan newSpan, RuntimeRudeEditDescription? runtimeRudeEdit)> expectedMapping,
        SyntaxMaps actualSyntaxMaps)
    {
        Contract.ThrowIfFalse(actualSyntaxMaps.HasMap);
 
        foreach (var (oldSpan, newSpan, expectedRuntimeRudeEdit) in expectedMapping)
        {
            var newNode = FindNode(newRoot, newSpan);
            var expectedOldNode = FindNode(oldRoot, oldSpan);
            var actualOldNode = actualSyntaxMaps.MatchingNodes(newNode);
            Assert.Equal(expectedOldNode, actualOldNode);
 
            var expected = expectedRuntimeRudeEdit?.GetMessage(newRoot.SyntaxTree);
            var actual = actualSyntaxMaps.RuntimeRudeEdits?.Invoke(newNode)?.Message;
 
            if (expected != actual)
            {
                Assert.Fail($"""
                    Unexpected runtime rude edit.
 
                    Expected message:
                    '{expected}'
                    Actual message:
                    '{actual}'
                    Node ({newNode.GetType().Name}):
                    `{newNode}`
                    """);
            }
        }
    }
 
    private void CreateProjects(EditScriptDescription[] editScripts, AdhocWorkspace workspace, TargetFramework targetFramework, out Project oldProject, out Project newProject)
    {
        var projectInfo = ProjectInfo.Create(
            new ProjectInfo.ProjectAttributes(
                id: ProjectId.CreateNewId(),
                version: VersionStamp.Create(),
                name: "project",
                assemblyName: "project",
                language: LanguageName,
                compilationOutputInfo: default,
                filePath: Path.Combine(TempRoot.Root, "project" + ProjectFileExtension),
                checksumAlgorithm: SourceHashAlgorithms.Default));
 
        oldProject = workspace.AddProject(projectInfo).WithMetadataReferences(TargetFrameworkUtil.GetReferences(targetFramework));
        foreach (var editScript in editScripts)
        {
            var oldRoot = editScript.Match.OldRoot;
            var oldPath = oldRoot.SyntaxTree.FilePath;
            var name = Path.GetFileNameWithoutExtension(oldPath);
            oldProject = oldProject.AddDocument(name, oldRoot, filePath: oldPath).Project;
        }
 
        var newSolution = oldProject.Solution;
        var documentIndex = 0;
        foreach (var oldDocument in oldProject.Documents)
        {
            newSolution = newSolution.WithDocumentSyntaxRoot(oldDocument.Id, editScripts[documentIndex].Match.NewRoot, PreservationMode.PreserveIdentity);
            documentIndex++;
        }
 
        newProject = newSolution.Projects.Single();
    }
 
    private static void AssertSpansEqual(IEnumerable<SourceFileSpan> expected, IEnumerable<SourceFileSpan> actual, SyntaxTree newTree)
    {
        AssertEx.Equal(
            expected,
            actual,
            itemSeparator: "\r\n",
            itemInspector: span => DisplaySpan(newTree, span));
    }
 
    private static string DisplaySpan(SyntaxTree tree, SourceFileSpan span)
    {
        if (tree.FilePath != span.Path)
        {
            return span.ToString();
        }
 
        var text = tree.GetText();
        var code = text.GetSubText(text.Lines.GetTextSpan(span.Span)).ToString().Replace("\r\n", " ");
        return $"{span}: [{code}]";
    }
 
    internal static IEnumerable<KeyValuePair<SyntaxNode, SyntaxNode>> GetMethodMatches(AbstractEditAndContinueAnalyzer analyzer, Match<SyntaxNode> bodyMatch)
    {
        Dictionary<LambdaBody, LambdaInfo>? lazyActiveOrMatchedLambdas = null;
        var map = analyzer.GetTestAccessor().IncludeLambdaBodyMaps(DeclarationBodyMap.FromMatch(bodyMatch), [], ref lazyActiveOrMatchedLambdas);
 
        var result = new Dictionary<SyntaxNode, SyntaxNode>();
        foreach (var pair in map.Forward)
        {
            if (pair.Value == bodyMatch.NewRoot)
            {
                Assert.Same(pair.Key, bodyMatch.OldRoot);
                continue;
            }
 
            result.Add(pair.Key, pair.Value);
        }
 
        return result;
    }
 
    public static MatchingPairs ToMatchingPairs(Match<SyntaxNode> match)
        => ToMatchingPairs(match.Matches.Where(partners => partners.Key != match.OldRoot));
 
    public static MatchingPairs ToMatchingPairs(IEnumerable<KeyValuePair<SyntaxNode, SyntaxNode>> matches)
    {
        return new MatchingPairs(matches
            .OrderBy(partners => partners.Key.GetLocation().SourceSpan.Start)
            .ThenByDescending(partners => partners.Key.Span.Length)
            .Select(partners => new MatchingPair
            {
                Old = partners.Key.ToString().Replace("\r\n", " ").Replace("\n", " "),
                New = partners.Value.ToString().Replace("\r\n", " ").Replace("\n", " ")
            }));
    }
 
    public static void SetDocumentsState(DebuggingSession session, Solution solution, CommittedSolution.DocumentState state)
    {
        foreach (var project in solution.Projects)
        {
            foreach (var document in project.Documents)
            {
                session.LastCommittedSolution.Test_SetDocumentState(document.Id, state);
            }
        }
    }
}