File: ConsoleLogsTests\LogEntriesTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Shared.ConsoleLogs;
using Xunit;
 
namespace Aspire.Dashboard.Tests.ConsoleLogsTests;
 
public class LogEntriesTests
{
    private static LogEntries CreateLogEntries(int? maximumEntryCount = null, int? baseLineNumber = null)
    {
        var logEntries = new LogEntries(maximumEntryCount: maximumEntryCount ?? int.MaxValue);
        logEntries.BaseLineNumber = baseLineNumber ?? 1;
        return logEntries;
    }
 
    private static void AddLogLine(LogEntries logEntries, string content, bool isError)
    {
        var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true);
        var logEntry = logParser.CreateLogEntry(content, isError, resourcePrefix: null);
        logEntries.InsertSorted(logEntry);
    }
 
    [Theory]
    [InlineData(int.MaxValue)]
    [InlineData(300)]
    [InlineData(40)]
    public void InsertSortedRange_MatchesInsertingEachEntryInTimestampOrder(int maximumEntryCount)
    {
        // Several resources' logs are interleaved: each batch spans the existing entries' time range, including entries
        // older than every buffered entry and entries with the same timestamp as buffered ones.
        var random = new Random(1234);
        var start = new DateTime(2024, 8, 19, 6, 0, 0, DateTimeKind.Utc);
        var oneByOne = CreateLogEntries(maximumEntryCount, baseLineNumber: 7);
        var merged = CreateLogEntries(maximumEntryCount, baseLineNumber: 7);
        var entryId = 0;
 
        List<(LogEntry OneByOne, LogEntry Merged)> CreateBatch(int count)
        {
            var batch = new List<(LogEntry, LogEntry)>();
            for (var i = 0; i < count; i++)
            {
                var line = $"{start.AddSeconds(random.Next(0, 500)):yyyy-MM-ddTHH:mm:ss.fffZ} entry {entryId++}";
                batch.Add((CreateEntry(line), CreateEntry(line)));
            }
            return batch;
        }
 
        foreach (var (one, other) in CreateBatch(20))
        {
            oneByOne.InsertSorted(one);
            merged.InsertSorted(other);
        }
 
        var total = 20;
        for (var round = 0; round < 12; round++)
        {
            var batch = CreateBatch(random.Next(16, 120));
            total += batch.Count;
            foreach (var (entry, _) in batch.OrderBy(pair => pair.OneByOne.Timestamp))
            {
                oneByOne.InsertSorted(entry);
            }
            merged.InsertSortedRange(batch.Select(pair => pair.Merged).ToList());
 
            // The same entries are kept, in the same order.
            Assert.Equal(oneByOne.GetEntries().Select(e => e.RawContent), merged.GetEntries().Select(e => e.RawContent));
 
            // Entries are numbered by their position in the whole history: consecutive, after the dropped entries.
            // Inserting one by one numbers the same way until the buffer is full.
            var expectedFirst = 7 + total - merged.EntriesCount;
            Assert.Equal(Enumerable.Range(expectedFirst, merged.EntriesCount), merged.GetEntries().Select(e => e.LineNumber));
            if (total <= maximumEntryCount)
            {
                Assert.Equal(oneByOne.GetEntries().Select(e => e.LineNumber), merged.GetEntries().Select(e => e.LineNumber));
            }
        }
 
        static LogEntry CreateEntry(string line) =>
            new LogParser(ConsoleColor.Black, encodeForHtml: true).CreateLogEntry(line, isErrorOutput: false, resourcePrefix: null);
    }
 
    [Fact]
    public void InsertSortedRange_WithPausedEntries_InsertsEachEntry()
    {
        var logEntries = CreateLogEntries();
        var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true);
        logEntries.InsertSorted(logParser.CreateLogEntry("2024-08-19T06:12:30.000Z last", isErrorOutput: false, resourcePrefix: null));
        logEntries.InsertSorted(LogEntry.CreatePause("resource", new DateTime(2024, 8, 19, 6, 12, 40, DateTimeKind.Utc)));
 
        logEntries.InsertSortedRange([.. Enumerable.Range(0, 20).Select(i =>
            logParser.CreateLogEntry($"2024-08-19T06:12:{i:00}.000Z entry {i}", isErrorOutput: false, resourcePrefix: null))]);
 
        Assert.Equal(22, logEntries.EntriesCount);
        Assert.Equal(LogEntryType.Pause, logEntries.GetEntries()[^1].Type);
        Assert.Equal(Enumerable.Range(1, 21), logEntries.GetEntries().Take(21).Select(e => e.LineNumber));
    }
 
    [Fact]
    public void Clear_AfterEarliestTimestampIndex_Success()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true);
 
        // Insert log with no timestamp.
        var logEntry1 = logParser.CreateLogEntry("Test", isErrorOutput: false, resourcePrefix: null);
        logEntries.InsertSorted(logEntry1);
 
        // Insert log with timestamp that ensures collections timestamp value is not zero.
        var logEntry2 = logParser.CreateLogEntry("2024-08-19T06:12:01.000Z Test", isErrorOutput: false, resourcePrefix: null);
        logEntries.InsertSorted(logEntry2);
 
        logEntries.Clear(keepActivePauseEntries: true);
        logEntries.BaseLineNumber = 0;
 
        // Insert another log entry after clearing.
        var logEntry3 = logParser.CreateLogEntry("2024-08-19T06:12:02.000Z Test", isErrorOutput: false, resourcePrefix: null);
        logEntries.InsertSorted(logEntry3);
 
        // Assert
        Assert.Single(logEntries.GetEntries());
    }
 
    [Fact]
    public void Add_PauseAndThenRemove_ActivePauseKept()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        AddLogLine(logEntries, "Hello world", isError: false);
 
        // Completed pause
        logEntries.InsertSorted(LogEntry.CreatePause(
            "resource-name",
            new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc),
            new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
 
        // Active pause
        var pauseEntry = LogEntry.CreatePause("resource-name", new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc));
        logEntries.InsertSorted(pauseEntry);
 
        var pauseVM = pauseEntry.Pause;
        Assert.NotNull(pauseVM);
        pauseVM.FilteredCount++;
 
        logEntries.Clear(keepActivePauseEntries: true);
 
        // Assert
        var entry = Assert.Single(logEntries.GetEntries());
        Assert.Equal(pauseEntry, entry);
        Assert.Equal(0, pauseVM.FilteredCount);
    }
 
    [Fact]
    public void AddLogLine_Single()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        AddLogLine(logEntries, "Hello world", isError: false);
 
        // Assert
        var entry = Assert.Single(logEntries.GetEntries());
        Assert.Equal("Hello world", entry.Content);
        Assert.Null(entry.Timestamp);
    }
 
    [Fact]
    public void AddLogLine_MultipleLines()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        AddLogLine(logEntries, "Hello world", isError: false);
        AddLogLine(logEntries, "Hello world 2", isError: false);
        AddLogLine(logEntries, "Hello world 3", isError: true);
 
        // Assert
        Assert.Collection(logEntries.GetEntries(),
            l => Assert.Equal("Hello world", l.Content),
            l => Assert.Equal("Hello world 2", l.Content),
            l => Assert.Equal("Hello world 3", l.Content));
    }
 
    [Fact]
    public void AddLogLine_MultipleLines_MixDatePrefix()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        AddLogLine(logEntries, "Hello world", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:10:01.000Z Hello world 2", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:10:02.000Z Hello world 3", isError: false);
        AddLogLine(logEntries, "Hello world 4", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:10:03.000Z Hello world 5", isError: false);
 
        // Assert
        var entries = logEntries.GetEntries();
        Assert.Collection(entries,
            l =>
            {
                Assert.Equal("Hello world", l.Content);
                Assert.Equal(1, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 2", l.Content);
                Assert.Equal(2, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 3", l.Content);
                Assert.Equal(3, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 4", l.Content);
                Assert.Equal(4, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 5", l.Content);
                Assert.Equal(5, l.LineNumber);
            });
    }
 
    [Fact]
    public void AddLogLine_MultipleLines_MixDatePrefix_OutOfOrder()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        AddLogLine(logEntries, "Hello world", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:12:00.000Z Hello world 2", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:11:00.000Z Hello world 3", isError: false);
        AddLogLine(logEntries, "Hello world 4", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:13:00.000Z Hello world 5", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:10:00.000Z Hello world 6", isError: false);
 
        // Assert
        var entries = logEntries.GetEntries();
        Assert.Collection(entries,
            l =>
            {
                Assert.Equal("Hello world", l.Content);
                Assert.Equal(1, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 6", l.Content);
                Assert.Equal(2, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 3", l.Content);
                Assert.Equal(3, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 2", l.Content);
                Assert.Equal(4, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 4", l.Content);
                Assert.Equal(5, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 5", l.Content);
                Assert.Equal(6, l.LineNumber);
            });
    }
 
    [Fact]
    public void AddLogLine_MultipleLines_SameDate_InOrder()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        // Act
        AddLogLine(logEntries, "2024-08-19T06:10:00.000Z Hello world 1", isError: false);
        AddLogLine(logEntries, "2024-08-19T06:10:00.000Z Hello world 2", isError: false);
 
        // Assert
        var entries = logEntries.GetEntries();
        Assert.Collection(entries,
            l =>
            {
                Assert.Equal("Hello world 1", l.Content);
                Assert.Equal(1, l.LineNumber);
            },
            l =>
            {
                Assert.Equal("Hello world 2", l.Content);
                Assert.Equal(2, l.LineNumber);
            });
    }
 
    [Fact]
    public void InsertSorted_OutOfOrderWithSameTimestamp_ReturnInOrder()
    {
        // Arrange
        var logEntries = CreateLogEntries();
 
        var timestamp = DateTime.UtcNow;
 
        // Act
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(1), "1", isErrorMessage: false));
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(3), "3", isErrorMessage: false));
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(2), "2", isErrorMessage: false));
 
        // Assert
        var entries = logEntries.GetEntries();
        Assert.Collection(entries,
            l => Assert.Equal("1", l.Content),
            l => Assert.Equal("2", l.Content),
            l => Assert.Equal("3", l.Content));
    }
 
    [Fact]
    public void InsertSorted_TrimsToMaximumEntryCount_Ordered()
    {
        // Arrange
        var logEntries = CreateLogEntries(maximumEntryCount: 2);
 
        var timestamp = DateTime.UtcNow;
 
        // Act
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(1), "1", isErrorMessage: false));
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(2), "2", isErrorMessage: false));
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(3), "3", isErrorMessage: false));
 
        // Assert
        var entries = logEntries.GetEntries();
        Assert.Collection(entries,
            l => Assert.Equal("2", l.Content),
            l => Assert.Equal("3", l.Content));
    }
 
    [Fact]
    public void InsertSorted_TrimsToMaximumEntryCount_OutOfOrder()
    {
        // Arrange
        var logEntries = CreateLogEntries(maximumEntryCount: 2);
 
        var timestamp = DateTime.UtcNow;
 
        // Act
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(1), "1", isErrorMessage: false));
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(3), "3", isErrorMessage: false));
        logEntries.InsertSorted(LogEntry.Create(timestamp.AddSeconds(2), "2", isErrorMessage: false));
 
        // Assert
        var entries = logEntries.GetEntries();
        Assert.Collection(entries,
            l => Assert.Equal("2", l.Content),
            l => Assert.Equal("3", l.Content));
    }
 
    [Fact]
    public void CreateLogEntry_AnsiAndUrl_HasUrlAnchor()
    {
        // Arrange
        var parser = new LogParser(ConsoleColor.Black, encodeForHtml: true);
 
        // Act
        var entry = parser.CreateLogEntry("\x1b[36mhttps://www.example.com\u001b[0m", isErrorOutput: false, resourcePrefix: null);
 
        // Assert
        Assert.Equal("<span class=\"ansi-fg-cyan\"></span><a target=\"_blank\" href=\"https://www.example.com\" rel=\"noopener noreferrer nofollow\">https://www.example.com</a>", entry.Content);
    }
 
    [Theory]
    [InlineData(ConsoleColor.Black, @"<span class=""ansi-fg-green"">info</span>: LoggerName")]
    [InlineData(ConsoleColor.Blue, @"<span class=""ansi-fg-green ansi-bg-black"">info</span>: LoggerName")]
    public void CreateLogEntry_DefaultBackgroundColor_SkipMatchingColor(ConsoleColor defaultBackgroundColor, string output)
    {
        // Arrange
        var parser = new LogParser(defaultBackgroundColor, encodeForHtml: true);
 
        // Act
        var entry = parser.CreateLogEntry("\u001b[40m\u001b[32minfo\u001b[39m\u001b[22m\u001b[49m: LoggerName", isErrorOutput: false, resourcePrefix: null);
 
        // Assert
        Assert.Equal(output, entry.Content);
    }
}