// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.Extensions.OutputDevice; using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.TestHost; namespace Microsoft.Testing.Platform.Services; /// /// The single, application-scoped consumer that correlates the raw coverage messages /// (, , /// ) into the read model. It is /// the single source of truth for coverage; the terminal output device and the exit-code policy both /// read from it rather than buffering their own copies. /// internal sealed class TestCoverageResult : ITestCoverageResult, IDataConsumer, IOutputDeviceDataProducer { private readonly ILoggerFactory? _loggerFactory; #if NET9_0_OR_GREATER private readonly Lock _lock = new(); #else private readonly object _lock = new(); #endif // Full correlation key per RFC 019: (SessionUid, ProducerId, Scope, Metric, CustomMetricName when // Metric == Custom). Last write wins for a duplicate full key. private readonly Dictionary _measurements = []; // Preserves first-seen order so the rendered summary is deterministic across runs. private readonly List _measurementOrder = []; private readonly Dictionary _thresholds = []; private readonly List _thresholdOrder = []; private readonly Dictionary _reports = []; private readonly List _reportOrder = []; private readonly List _controllerSummaryArtifacts = []; private ILogger? _logger; private ICommandLineOptions? _thresholdOptions; private SessionUid? _thresholdSessionUid; private bool _deferToController; public TestCoverageResult() { } public TestCoverageResult(ILoggerFactory loggerFactory) => _loggerFactory = loggerFactory; public string Uid => nameof(TestCoverageResult); public string Version => PlatformVersion.Version; public string DisplayName => "Test coverage result"; public string Description => "Consumes and correlates test coverage data, threshold results, and report references."; public Type[] DataTypesConsumed { get; } = [typeof(TestCoverageMessage), typeof(TestCoverageThresholdMessage), typeof(TestCoverageReportMessage), typeof(SessionFileArtifact)]; internal SessionFileArtifact[] ControllerSummaryArtifacts { get { lock (_lock) { return [.. _controllerSummaryArtifacts]; } } } internal void DeferToController() => _deferToController = true; public IReadOnlyList Thresholds { get { lock (_lock) { var result = new List(_thresholdOrder.Count); foreach (ThresholdKey key in _thresholdOrder) { result.Add(_thresholds[key]); } if (_thresholdOptions is not null) { result.AddRange(CoverageThresholdPolicy.Evaluate(_thresholdOptions, Scopes, _thresholdSessionUid).Thresholds); } return result; } } } public bool HasThresholdFailure { get { foreach (TestCoverageThresholdMessage threshold in Thresholds) { if (!threshold.Passed) { return true; } } return false; } } public CoverageScopeSummary? GetOverall(SessionUid sessionUid) { foreach (CoverageScopeSummary summary in Scopes) { if (summary.Scope.Level == CoverageScopeLevel.Overall && string.Equals(summary.SessionUid.Value, sessionUid.Value, StringComparison.Ordinal)) { return summary; } } return null; } public IReadOnlyList Scopes { get { lock (_lock) { // Group correlated measurements by (SessionUid, Scope) preserving first-seen order. Two // sessions reporting the same scope are kept as separate summaries; ContainerHint is not part // of scope identity so hints from different producers never split a scope. var order = new List<(string Session, CoverageScope Scope)>(); var groups = new Dictionary<(string Session, CoverageScope Scope), List>(); foreach (MeasurementKey key in _measurementOrder) { TestCoverageMessage message = _measurements[key]; (string, CoverageScope) groupKey = (message.SessionUid.Value, message.Scope); if (!groups.TryGetValue(groupKey, out List? metrics)) { metrics = []; groups[groupKey] = metrics; order.Add(groupKey); } metrics.Add(new CoverageMetricResult( message.Metric, message.CoveredCount, message.CoverableCount, message.ProducerId, message.CustomMetricName)); } var result = new List(order.Count); foreach ((string Session, CoverageScope Scope) groupKey in order) { result.Add(new CoverageScopeSummary(new SessionUid(groupKey.Session), groupKey.Scope, groups[groupKey])); } return result; } } } public IReadOnlyList Reports { get { lock (_lock) { var result = new List(_reportOrder.Count); foreach (ReportKey key in _reportOrder) { result.Add(_reports[key]); } return result; } } } public Task IsEnabledAsync() => Task.FromResult(true); internal void ConfigureThresholds(ICommandLineOptions commandLineOptions, SessionUid? sessionUid) { lock (_lock) { _thresholdOptions = commandLineOptions; _thresholdSessionUid = sessionUid; } } internal IReadOnlyList GetThresholdErrors() { lock (_lock) { return _thresholdOptions is null ? [] : CoverageThresholdPolicy.Evaluate(_thresholdOptions, Scopes, _thresholdSessionUid).Errors; } } /// /// Clears all accumulated coverage data. Used to reset the per-session state between hot-reload /// cycles, which reuse the same application-scoped instance. /// public void Reset() { lock (_lock) { _measurements.Clear(); _measurementOrder.Clear(); _thresholds.Clear(); _thresholdOrder.Clear(); _reports.Clear(); _reportOrder.Clear(); _thresholdOptions = null; _thresholdSessionUid = null; _controllerSummaryArtifacts.Clear(); _deferToController = false; } } public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) { switch (value) { case SessionFileArtifact artifact when _deferToController && artifact.Kind is string kind && kind.EndsWith("-summary-fragment", StringComparison.Ordinal): lock (_lock) { _controllerSummaryArtifacts.Add(artifact); } return Task.CompletedTask; case TestCoverageMessage coverage: var measurementKey = new MeasurementKey( coverage.SessionUid.Value, coverage.ProducerId, coverage.Scope.Level, coverage.Scope.Name, coverage.Metric, coverage.Metric == CoverageMetric.Custom ? coverage.CustomMetricName : null); // Last write wins on a duplicate full key, keeping the original position for stable order. bool replacesMeasurement; lock (_lock) { replacesMeasurement = _measurements.ContainsKey(measurementKey); if (!replacesMeasurement) { _measurementOrder.Add(measurementKey); } _measurements[measurementKey] = coverage; } return replacesMeasurement ? LogCorrectionAsync(nameof(TestCoverageMessage), coverage.SessionUid.Value, coverage.ProducerId) : Task.CompletedTask; case TestCoverageThresholdMessage threshold: var thresholdKey = new ThresholdKey( threshold.SessionUid.Value, threshold.ProducerId, threshold.Scope.Level, threshold.Scope.Name, threshold.Metric, threshold.Metric == CoverageMetric.Custom ? threshold.CustomMetricName : null, threshold.Aggregation, threshold.AggregatedOver); bool replacesThreshold; lock (_lock) { replacesThreshold = _thresholds.ContainsKey(thresholdKey); if (!replacesThreshold) { _thresholdOrder.Add(thresholdKey); } _thresholds[thresholdKey] = threshold; } return replacesThreshold ? LogCorrectionAsync(nameof(TestCoverageThresholdMessage), threshold.SessionUid.Value, threshold.ProducerId) : Task.CompletedTask; case TestCoverageReportMessage report: var reportKey = new ReportKey(report.SessionUid.Value, report.ProducerId, report.ReportPath); bool replacesReport; lock (_lock) { replacesReport = _reports.ContainsKey(reportKey); if (!replacesReport) { _reportOrder.Add(reportKey); } _reports[reportKey] = new CoverageReportReference( report.SessionUid, report.ReportPath, report.Format, report.ProducerId, report.CustomFormatName); } return replacesReport ? LogCorrectionAsync(nameof(TestCoverageReportMessage), report.SessionUid.Value, report.ProducerId) : Task.CompletedTask; } return Task.CompletedTask; } private Task LogCorrectionAsync(string messageType, string sessionUid, string producerId) { if (_loggerFactory is null) { return Task.CompletedTask; } ILogger logger; lock (_lock) { _logger ??= _loggerFactory.CreateLogger(); logger = _logger; } return logger.LogDebugAsync( $"Replacing duplicate {messageType} from producer '{producerId}' for session '{sessionUid}' using last-write-wins correlation."); } private readonly record struct MeasurementKey( string Session, string ProducerId, CoverageScopeLevel Level, string? Name, CoverageMetric Metric, string? CustomMetricName); private readonly record struct ThresholdKey( string Session, string ProducerId, CoverageScopeLevel Level, string? Name, CoverageMetric Metric, string? CustomMetricName, CoverageAggregation Aggregation, CoverageScopeLevel? AggregatedOver); private readonly record struct ReportKey(string Session, string ProducerId, string Path); }