Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions GitHubActionsTestLogger.Tests/Utils/Extensions/FileExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.IO;

namespace GitHubActionsTestLogger.Tests.Utils.Extensions;

internal static class FileExtensions
{
extension(File)
{
// Writes 'count' zero bytes to a file without allocating a large buffer.
// Uses SetLength to expand the file on disk via a single system call.
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
public static void WriteAllZeroes(string path, long count)
{
using var fs = new FileStream(
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 1
);
fs.SetLength(count);
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
}
}
}
171 changes: 171 additions & 0 deletions GitHubActionsTestLogger.Tests/VsTestSummarySpecs.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using FluentAssertions;
using GitHubActionsTestLogger.Tests.Utils.Extensions;
using GitHubActionsTestLogger.Tests.VsTest;
using Xunit;
using Xunit.Abstractions;
Expand Down Expand Up @@ -352,4 +354,173 @@ public void I_can_use_the_logger_to_produce_a_summary_that_does_not_include_empt

testOutput.WriteLine(output);
}

[Fact]
public void I_can_try_to_use_the_logger_to_produce_a_summary_when_the_output_file_is_nearly_full_and_get_a_truncated_summary()
Comment thread
Tyrrrz marked this conversation as resolved.
{
// Arrange
var summaryFilePath = Path.GetTempFileName();
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
try
{
// Pre-fill the summary file to within ~1000 bytes of the 1 MiB limit.
// This forces any summary larger than ~1000 bytes to be truncated.
const int prefillSize = 1024 * 1024 - 1000;
File.WriteAllZeroes(summaryFilePath, prefillSize);

using var commandWriter = new StringWriter();

var events = new FakeTestLoggerEvents();
var logger = new VsTestLogger();

// Use a file-backed StreamWriter so GitHubWorkflow can detect the file path via cast
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
using (
var summaryWriter = new StreamWriter(
new FileStream(
summaryFilePath,
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite
)
)
)
{
logger.Initialize(
events,
new Dictionary<string, string?> { ["summary-include-passed"] = "true" },
commandWriter,
summaryWriter
);

// Act — simulate a run with multiple test groups to produce a multi-group summary
events.SimulateTestRun(
// Group A
new TestResultBuilder()
.SetDisplayName("TestGroupA_LongTestName_One")
.SetFullyQualifiedName("TestProject.GroupA.TestGroupA_LongTestName_One")
.SetOutcome(TestOutcome.Passed)
.Build(),
new TestResultBuilder()
.SetDisplayName("TestGroupA_LongTestName_Two")
.SetFullyQualifiedName("TestProject.GroupA.TestGroupA_LongTestName_Two")
.SetOutcome(TestOutcome.Failed)
.SetErrorMessage("Expected: something, but got: something else (GroupA)")
.Build(),
// Group B
new TestResultBuilder()
.SetDisplayName("TestGroupB_LongTestName_One")
.SetFullyQualifiedName("TestProject.GroupB.TestGroupB_LongTestName_One")
.SetOutcome(TestOutcome.Passed)
.Build(),
new TestResultBuilder()
.SetDisplayName("TestGroupB_LongTestName_Two")
.SetFullyQualifiedName("TestProject.GroupB.TestGroupB_LongTestName_Two")
.SetOutcome(TestOutcome.Failed)
.SetErrorMessage("Expected: something, but got: something else (GroupB)")
.Build(),
// Group C
new TestResultBuilder()
.SetDisplayName("TestGroupC_LongTestName_One")
.SetFullyQualifiedName("TestProject.GroupC.TestGroupC_LongTestName_One")
.SetOutcome(TestOutcome.Passed)
.Build(),
new TestResultBuilder()
.SetDisplayName("TestGroupC_LongTestName_Two")
.SetFullyQualifiedName("TestProject.GroupC.TestGroupC_LongTestName_Two")
.SetOutcome(TestOutcome.Failed)
.SetErrorMessage("Expected: something, but got: something else (GroupC)")
.Build()
);
} // Dispose writer before reading to ensure content is fully flushed

// Assert
var commandOutput = commandWriter.ToString();

// Read only the bytes appended after the pre-fill
var fileBytes = File.ReadAllBytes(summaryFilePath);
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
var summaryOutput = Encoding.UTF8.GetString(
fileBytes,
prefillSize,
fileBytes.Length - prefillSize
);

// A truncation warning annotation should have been written
commandOutput.Should().Contain("::warning");
commandOutput.Should().Contain("truncated");

// The summary should still be written with valid HTML (legible truncation)
summaryOutput.Should().Contain("<details>");
summaryOutput.Should().Contain("</details>");
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated

testOutput.WriteLine("Command output:");
testOutput.WriteLine(commandOutput);
testOutput.WriteLine("Summary output:");
testOutput.WriteLine(summaryOutput);
}
finally
{
File.Delete(summaryFilePath);
}
}

[Fact]
public void I_can_try_to_use_the_logger_to_produce_a_summary_when_the_output_file_is_full_and_get_the_summary_omitted()
{
// Arrange
var summaryFilePath = Path.GetTempFileName();
try
{
// Pre-fill the summary file to within ~100 bytes of the 1 MiB limit.
// This leaves insufficient room for even the smallest legible summary.
const int prefillSize = 1024 * 1024 - 100;
File.WriteAllZeroes(summaryFilePath, prefillSize);

using var commandWriter = new StringWriter();

var events = new FakeTestLoggerEvents();
var logger = new VsTestLogger();

// Use a file-backed StreamWriter so GitHubWorkflow can detect the file path via cast
using (
var summaryWriter = new StreamWriter(
new FileStream(
summaryFilePath,
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite
)
)
)
{
logger.Initialize(
events,
new Dictionary<string, string?> { ["summary-allow-empty"] = "true" },
commandWriter,
summaryWriter
);

// Act
events.SimulateTestRun("TestProject.dll");
} // Dispose writer before reading to ensure content is fully flushed

// Assert
var commandOutput = commandWriter.ToString();

// File should be exactly the prefill size (no summary content appended)
var fileLength = new FileInfo(summaryFilePath).Length;

// An omission warning annotation should have been written
commandOutput.Should().Contain("::warning");
commandOutput.Should().Contain("omitted");

// No summary content should have been written
fileLength.Should().Be(prefillSize);

testOutput.WriteLine("Command output:");
testOutput.WriteLine(commandOutput);
}
finally
{
File.Delete(summaryFilePath);
}
}
}
157 changes: 157 additions & 0 deletions GitHubActionsTestLogger/GitHub/GitHubWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using GitHubActionsTestLogger.Utils;
using GitHubActionsTestLogger.Utils.Extensions;
Expand All @@ -11,6 +12,10 @@ namespace GitHubActionsTestLogger.GitHub;
// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
internal partial class GitHubWorkflow(TextWriter commandWriter, TextWriter summaryWriter)
{
// GitHub step summary file size limit (1 MiB)
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary
private const long SummaryFileSizeLimit = 1024 * 1024;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this in GitHubEnvironment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fb88931 — moved to GitHubEnvironment as public const long SummaryFileSizeLimit = 1024 * 1024; and updated all references in GitHubWorkflow to use GitHubEnvironment.SummaryFileSizeLimit.


private async Task InvokeCommandAsync(
string command,
string message,
Expand Down Expand Up @@ -65,8 +70,65 @@ public async Task CreateErrorAnnotationAsync(
await InvokeCommandAsync("error", message, options);
}

public async Task CreateWarningAnnotationAsync(string title, string message)
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
await InvokeCommandAsync(
"warning",
message,
new Dictionary<string, string> { ["title"] = title }
);
}

public async Task CreateSummaryAsync(string content)
{
// Try to extract the underlying file path from the summary writer to monitor file size.
// This works when the writer wraps a ContentionTolerantWriteFileStream (production)
// or a plain FileStream (tests).
var detectedFilePath = summaryWriter is StreamWriter sw
? sw.BaseStream switch
{
ContentionTolerantWriteFileStream cts => cts.FilePath,
FileStream fs => fs.Name,
_ => null,
}
: null;

if (detectedFilePath != null)
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
var existingSize = File.Exists(detectedFilePath)
? new FileInfo(detectedFilePath).Length
: 0L;

var newlineSize = Encoding.UTF8.GetByteCount(Environment.NewLine);
var contentSize = Encoding.UTF8.GetByteCount(content);

// Two leading newlines + content + trailing newline
var totalToWrite = newlineSize * 3 + contentSize;

if (existingSize + totalToWrite > SummaryFileSizeLimit)
{
var availableBytes = (int)(SummaryFileSizeLimit - existingSize - newlineSize * 3);
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated

var truncated = TryTruncateSummary(content, availableBytes);

if (truncated == null)
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
// Can't produce a legible summary — skip writing entirely
await CreateWarningAnnotationAsync(
"GitHub Actions Test Logger",
BuildSummaryOmittedWarning(isSharedFile: existingSize > 0)
);
return;
}

await CreateWarningAnnotationAsync(
"GitHub Actions Test Logger",
BuildSummaryTruncatedWarning(isSharedFile: existingSize > 0)
);
content = truncated;
}
}

// 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
Expand All @@ -78,6 +140,101 @@ public async Task CreateSummaryAsync(string content)
await summaryWriter.WriteLineAsync(content);
await summaryWriter.FlushAsync();
}

// Attempt to truncate the rendered summary HTML so that it fits within maxBytes.
// Returns the truncated content, or null if even the minimal legible content won't fit.
private static string? TryTruncateSummary(string content, int maxBytes)
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
if (maxBytes <= 0)
return null;

// Fast path: content already fits
if (Encoding.UTF8.GetByteCount(content) <= maxBytes)
return content;

// HTML pattern that marks the end of a test-group list item in the outer <ul>.
// Each group closes as: </ul><p></p></li> (inner results list, margin, group item)
const string groupItemClose = "</ul><p></p></li>";

// Suffix to append after a group-item cut to produce valid HTML
const string groupCutSuffix = "</ul></details>";

// Minimum legible content ends right after </table>;
// just close the <details> element to produce a valid (stats-only) summary.
const string tableEndTag = "</table>";
const string tableCutSuffix = "</details>";

var tableEndIndex = content.LastIndexOf(tableEndTag, StringComparison.Ordinal);
if (tableEndIndex < 0)
return null;

var tableContentEnd = tableEndIndex + tableEndTag.Length;

// Check whether even the minimal (stats-table-only) content fits.
// Accumulate byte counts incrementally to avoid O(n²) recalculation.
var tableCutSuffixBytes = Encoding.UTF8.GetByteCount(tableCutSuffix);
var tableContentBytes = Encoding.UTF8.GetByteCount(content.Substring(0, tableContentEnd));
if (tableContentBytes + tableCutSuffixBytes > maxBytes)
return null;

// Try to include as many complete test groups as possible
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
var groupCutSuffixBytes = Encoding.UTF8.GetByteCount(groupCutSuffix);
var bestCutPoint = tableContentEnd;
var bestCutSuffix = tableCutSuffix;

var currentBytes = tableContentBytes;
var searchFrom = tableContentEnd;
while (true)
{
var groupEnd = content.IndexOf(groupItemClose, searchFrom, StringComparison.Ordinal);
if (groupEnd < 0)
break;

var cutPoint = groupEnd + groupItemClose.Length;

// Count only the bytes of the new segment (incremental accumulation)
currentBytes += Encoding.UTF8.GetByteCount(
content.Substring(searchFrom, cutPoint - searchFrom)
);

if (currentBytes + groupCutSuffixBytes > maxBytes)
break; // This group doesn't fit — stop

bestCutPoint = cutPoint;
bestCutSuffix = groupCutSuffix;
searchFrom = cutPoint;
}

return content.Substring(0, bestCutPoint) + bestCutSuffix;
}

private static string BuildSummaryTruncatedWarning(bool isSharedFile)
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
var message =
"The test summary was truncated because it exceeded GitHub's step summary size limit (1 MiB). "
+ "To reduce the summary size, consider excluding passed tests by setting "
+ "`summary-include-passed=false` or skipped tests by setting `summary-include-skipped=false`.";

if (isSharedFile)
message +=
" The summary file is shared with other test steps — consider splitting them into separate jobs.";

return message;
}

private static string BuildSummaryOmittedWarning(bool isSharedFile)
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
{
var message =
"The test summary was omitted because it exceeded GitHub's step summary size limit (1 MiB). "
+ "To reduce the summary size, consider excluding passed tests by setting "
+ "`summary-include-passed=false` or skipped tests by setting `summary-include-skipped=false`.";

if (isSharedFile)
message +=
" The summary file is shared with other test steps — consider splitting them into separate jobs.";

return message;
}
}

internal partial class GitHubWorkflow
Expand Down
Loading