diff --git a/Directory.Packages.props b/Directory.Packages.props index 772edb4..6dbed63 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,6 @@ true - @@ -12,9 +11,9 @@ - + - + \ No newline at end of file diff --git a/GitHubActionsTestLogger.Tests/MtpSummarySpecs.cs b/GitHubActionsTestLogger.Tests/MtpSummarySpecs.cs index ff5dcfc..90dd1fd 100644 --- a/GitHubActionsTestLogger.Tests/MtpSummarySpecs.cs +++ b/GitHubActionsTestLogger.Tests/MtpSummarySpecs.cs @@ -387,4 +387,66 @@ public async Task I_can_try_to_use_the_logger_to_produce_a_summary_when_the_outp testOutput.WriteLine("Summary output:"); testOutput.WriteLine(summaryOutput); } + + [Fact] + public async Task I_can_try_to_use_the_logger_to_produce_a_summary_when_the_output_file_is_nearly_full_and_content_contains_non_ascii_characters_and_get_a_truncated_summary_within_the_size_limit() + { + // Arrange + using var testResultsDir = TempDirectory.Create(); + using var summaryFile = TempFile.Create(); + + const int prefillSize = 1024 * 1024 - 250; + File.WriteAllZeroes(summaryFile.Path, prefillSize); + + using var commandWriter = new StringWriter(); + + // Use a file-backed StreamWriter so that the file path is exposed internally + await using var summaryFileStream = File.Open( + summaryFile.Path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite + ); + + await using var summaryWriter = new StreamWriter(summaryFileStream); + + var builder = await TestApplication.CreateBuilderAsync([ + "--results-directory", + testResultsDir.Path, + "--report-github", + "--report-github-summary-include-passed", + ]); + + // Use non-ASCII characters in test names (each Chinese character is 3 bytes in UTF-8, + // so truncating by char count instead of byte count would exceed the size limit) + builder.RegisterFakeTests( + new TestNodeBuilder() + .SetDisplayName(new string('一', 100)) + .SetOutcome(TestOutcome.Failed) + .Build(), + new TestNodeBuilder() + .SetDisplayName(new string('二', 100)) + .SetOutcome(TestOutcome.Failed) + .Build() + ); + + builder.AddGitHubActionsReporting(commandWriter, summaryWriter); + + // Act + var app = await builder.BuildAsync(); + await app.RunAsync(); + + await summaryWriter.FlushAsync(); + + // Assert + var commandOutput = commandWriter.ToString(); + var summaryFileSize = new FileInfo(summaryFile.Path).Length; + + commandOutput.Should().ContainAll("::warning", "truncated"); + summaryFileSize.Should().BeLessThanOrEqualTo(1024 * 1024); + + testOutput.WriteLine($"Summary file size: {summaryFileSize}"); + testOutput.WriteLine("Command output:"); + testOutput.WriteLine(commandOutput); + } } diff --git a/GitHubActionsTestLogger.Tests/VsTestSummarySpecs.cs b/GitHubActionsTestLogger.Tests/VsTestSummarySpecs.cs index b34035a..4911875 100644 --- a/GitHubActionsTestLogger.Tests/VsTestSummarySpecs.cs +++ b/GitHubActionsTestLogger.Tests/VsTestSummarySpecs.cs @@ -432,4 +432,65 @@ public async Task I_can_try_to_use_the_logger_to_produce_a_summary_when_the_outp testOutput.WriteLine("Summary output:"); testOutput.WriteLine(summaryOutput); } + + [Fact] + public async Task I_can_try_to_use_the_logger_to_produce_a_summary_when_the_output_file_is_nearly_full_and_content_contains_non_ascii_characters_and_get_a_truncated_summary_within_the_size_limit() + { + // Arrange + using var summaryFile = TempFile.Create(); + + const int prefillSize = 1024 * 1024 - 250; + File.WriteAllZeroes(summaryFile.Path, prefillSize); + + using var commandWriter = new StringWriter(); + + var events = new FakeTestLoggerEvents(); + var logger = new VsTestLogger(); + + // Use a file-backed StreamWriter so that the file path is exposed internally + using var summaryFileStream = File.Open( + summaryFile.Path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite + ); + + using var summaryWriter = new StreamWriter(summaryFileStream); + + logger.Initialize( + events, + new Dictionary { ["summary-include-passed"] = "true" }, + commandWriter, + summaryWriter + ); + + // Act + // Use non-ASCII characters in test names (each Chinese character is 3 bytes in UTF-8, + // so truncating by char count instead of byte count would exceed the size limit) + events.SimulateTestRun( + new TestResultBuilder() + .SetDisplayName(new string('一', 100)) + .SetFullyQualifiedName("TestProject.SomeTests.Test1") + .SetOutcome(TestOutcome.Failed) + .Build(), + new TestResultBuilder() + .SetDisplayName(new string('二', 100)) + .SetFullyQualifiedName("TestProject.SomeTests.Test2") + .SetOutcome(TestOutcome.Failed) + .Build() + ); + + await summaryWriter.FlushAsync(); + + // Assert + var commandOutput = commandWriter.ToString(); + var summaryFileSize = new FileInfo(summaryFile.Path).Length; + + commandOutput.Should().ContainAll("::warning", "truncated"); + summaryFileSize.Should().BeLessThanOrEqualTo(1024 * 1024); + + testOutput.WriteLine($"Summary file size: {summaryFileSize}"); + testOutput.WriteLine("Command output:"); + testOutput.WriteLine(commandOutput); + } } diff --git a/GitHubActionsTestLogger/GitHub/GitHubWorkflow.cs b/GitHubActionsTestLogger/GitHub/GitHubWorkflow.cs index c22aa11..3d4a112 100644 --- a/GitHubActionsTestLogger/GitHub/GitHubWorkflow.cs +++ b/GitHubActionsTestLogger/GitHub/GitHubWorkflow.cs @@ -102,28 +102,15 @@ private string TruncateSummary(string content) return content; var existingSize = File.Exists(filePath) ? new FileInfo(filePath).Length : 0L; - - // Calculate required size for the summary content var contentSize = Encoding.UTF8.GetByteCount(content); - var newLineSize = Encoding.UTF8.GetByteCount(Environment.NewLine); - var requiredSize = contentSize + newLineSize * 3; - if (existingSize + requiredSize > GitHubEnvironment.SummaryFileSizeLimit) + if (existingSize + contentSize > GitHubEnvironment.SummaryFileSizeLimit) { var availableSize = (int) - Math.Min( - GitHubEnvironment.SummaryFileSizeLimit - existingSize - newLineSize * 3L, - int.MaxValue - ); - - return - // There is enough space to fit the whole content - availableSize > 0 - && requiredSize <= availableSize - ? content - // There is enough space to fit some of the content - : availableSize > 0 && requiredSize > availableSize ? content[..availableSize] - // There is no space at all + Math.Min(GitHubEnvironment.SummaryFileSizeLimit - existingSize, int.MaxValue); + + return availableSize > 0 + ? content.TruncateBytes(availableSize, Encoding.UTF8) : string.Empty; } @@ -132,9 +119,19 @@ private string TruncateSummary(string content) public async Task CreateSummaryAsync(string content) { + // If the summary file already contains HTML content, we need to first add two newlines + // in order to switch GitHub's parser from HTML mode back to markdown mode. + // It's safe to do it unconditionally because, if the file is empty, these newlines + // will simply be ignored. + // https://github.com/Tyrrrz/GitHubActionsTestLogger/issues/22 + // The newlines are included in the content before truncation so that the byte budget + // is always accurate and the file never exceeds the size limit. + var actualContent = + Environment.NewLine + Environment.NewLine + content + Environment.NewLine; + // Truncate summary to fit into GitHub's step summary size limit - var truncated = TruncateSummary(content); - if (truncated.Length < content.Length) + var truncatedContent = TruncateSummary(actualContent); + if (truncatedContent.Length < actualContent.Length) { await CreateWarningAnnotationAsync( "Test summary truncated", @@ -147,15 +144,7 @@ If you have multiple summary providers in the same step (e.g. running multiple t ); } - // If the summary file already contains HTML content, we need to first add two newlines - // in order to switch GitHub's parser from HTML mode back to markdown mode. - // It's safe to do it unconditionally because, if the file is empty, these newlines - // will simply be ignored. - // https://github.com/Tyrrrz/GitHubActionsTestLogger/issues/22 - await summaryWriter.WriteLineAsync(); - await summaryWriter.WriteLineAsync(); - - await summaryWriter.WriteLineAsync(content); + await summaryWriter.WriteAsync(truncatedContent); await summaryWriter.FlushAsync(); } }