From 5eea7299d2fb480b6eb27cd07538900313f51344 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 21:52:16 -0500 Subject: [PATCH 1/8] Do not truncate when not attached to a console --- src/AppInstallerCLICore/ChannelStreams.cpp | 4 +-- src/AppInstallerCLICore/ChannelStreams.h | 6 ++-- src/AppInstallerCLICore/ExecutionProgress.cpp | 2 +- src/AppInstallerCLICore/TableOutput.h | 29 ++++++++++++------- .../Workflows/ConfigurationFlow.cpp | 5 ++-- .../Workflows/WorkflowBase.cpp | 2 +- 6 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/AppInstallerCLICore/ChannelStreams.cpp b/src/AppInstallerCLICore/ChannelStreams.cpp index b152fd2acd..39e0fa33f3 100644 --- a/src/AppInstallerCLICore/ChannelStreams.cpp +++ b/src/AppInstallerCLICore/ChannelStreams.cpp @@ -8,7 +8,7 @@ namespace AppInstaller::CLI::Execution { using namespace VirtualTerminal; - size_t GetConsoleWidth() + std::optional GetConsoleWidth() { CONSOLE_SCREEN_BUFFER_INFO consoleInfo{}; if (GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &consoleInfo)) @@ -17,7 +17,7 @@ namespace AppInstaller::CLI::Execution } else { - return 120; + return std::nullopt; } } diff --git a/src/AppInstallerCLICore/ChannelStreams.h b/src/AppInstallerCLICore/ChannelStreams.h index 4a1d66cdc1..8e168e380e 100644 --- a/src/AppInstallerCLICore/ChannelStreams.h +++ b/src/AppInstallerCLICore/ChannelStreams.h @@ -5,14 +5,16 @@ #include "VTSupport.h" #include +#include #include #include namespace AppInstaller::CLI::Execution { - // Gets the current console width. - size_t GetConsoleWidth(); + // Gets the current console width, or std::nullopt if stdout is not attached to a console + // (e.g. redirected to a file or pipe). Callers that receive nullopt should not truncate output. + std::optional GetConsoleWidth(); // The base stream for all channels. struct BaseStream diff --git a/src/AppInstallerCLICore/ExecutionProgress.cpp b/src/AppInstallerCLICore/ExecutionProgress.cpp index 562e293007..2e9fb14cda 100644 --- a/src/AppInstallerCLICore/ExecutionProgress.cpp +++ b/src/AppInstallerCLICore/ExecutionProgress.cpp @@ -161,7 +161,7 @@ namespace AppInstaller::CLI::Execution } else { - m_out << '\r' << std::string(GetConsoleWidth(), ' ') << '\r'; + m_out << '\r' << std::string(GetConsoleWidth().value_or(0), ' ') << '\r'; } } diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index 6916602e40..f982d3b116 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -21,7 +21,8 @@ namespace AppInstaller::CLI::Execution using line_t = std::array; TableOutput(Reporter& reporter, header_t&& header, size_t sizingBuffer = 50) : - m_reporter(reporter), m_sizingBuffer(sizingBuffer) + m_reporter(reporter), m_sizingBuffer(sizingBuffer), + m_hasConsole(GetConsoleWidth().has_value()) { for (size_t i = 0; i < FieldCount; ++i) { @@ -35,14 +36,18 @@ namespace AppInstaller::CLI::Execution { m_empty = false; - if (m_buffer.size() < m_sizingBuffer) + // When a console is present, stream rows beyond the sizing buffer directly to avoid + // holding all data in memory. When there is no console (redirected output), buffer + // every row so that column widths are computed from the full dataset before any + // output is written, guaranteeing perfect alignment with no truncation. + if (m_hasConsole && m_buffer.size() >= m_sizingBuffer) { - m_buffer.emplace_back(std::move(line)); + EvaluateAndFlushBuffer(); + OutputLineToStream(line); } else { - EvaluateAndFlushBuffer(); - OutputLineToStream(line); + m_buffer.emplace_back(std::move(line)); } } @@ -75,6 +80,7 @@ namespace AppInstaller::CLI::Execution std::vector m_buffer; bool m_bufferEvaluated = false; bool m_empty = true; + bool m_hasConsole = false; void EvaluateAndFlushBuffer() { @@ -127,13 +133,14 @@ namespace AppInstaller::CLI::Execution totalRequired += m_columns[i].MaxLength + (m_columns[i].SpaceAfter ? 1 : 0); } - size_t consoleWidth = GetConsoleWidth(); + auto consoleWidthOpt = GetConsoleWidth(); - // If the total space would be too big, shrink them. - // We don't want to use the last column, lest we auto-wrap - if (totalRequired >= consoleWidth) + // If there is a console and the total space would be too big, shrink columns. + // We don't want to use the last column, lest we auto-wrap. + // When there is no console (e.g. output redirected to a file), skip truncation entirely. + if (consoleWidthOpt && totalRequired >= *consoleWidthOpt) { - size_t extra = (totalRequired - consoleWidth) + 1; + size_t extra = (totalRequired - *consoleWidthOpt) + 1; while (extra) { @@ -151,7 +158,7 @@ namespace AppInstaller::CLI::Execution extra -= 1; } - totalRequired = consoleWidth - 1; + totalRequired = *consoleWidthOpt - 1; } // Header line diff --git a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp index 224a3a31bf..9dd0ea4fad 100644 --- a/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/ConfigurationFlow.cpp @@ -19,6 +19,7 @@ #include #include #include +#include using namespace AppInstaller::CLI::Execution; using namespace winrt::Microsoft::Management::Configuration; @@ -356,7 +357,7 @@ namespace AppInstaller::CLI::Workflow truncated = true; } - if (Utility::LimitOutputLines(lines, GetConsoleWidth(), maxLines)) + if (Utility::LimitOutputLines(lines, GetConsoleWidth().value_or(std::numeric_limits::max()), maxLines)) { truncated = true; } @@ -767,7 +768,7 @@ namespace AppInstaller::CLI::Workflow if (messageData.ShowDescription && !description.empty()) { constexpr size_t maximumDescriptionLines = 3; - size_t consoleWidth = GetConsoleWidth(); + size_t consoleWidth = GetConsoleWidth().value_or(std::numeric_limits::max()); std::vector lines = Utility::SplitIntoLines(description, maximumDescriptionLines + 1); bool wasLimited = Utility::LimitOutputLines(lines, consoleWidth, maximumDescriptionLines); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 09b6e2b100..7a5f2ef16e 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -113,7 +113,7 @@ namespace AppInstaller::CLI::Workflow { // Using a height of 4 arbitrarily; allow width up to the entire console. UINT imageHeightCells = 4; - UINT imageWidthCells = static_cast(Execution::GetConsoleWidth()); + UINT imageWidthCells = static_cast(Execution::GetConsoleWidth().value_or(120)); icon.RenderSizeInCells(imageWidthCells, imageHeightCells); icon.RenderTo(outputStream); From bfe4c7acbf67a180d1bbc1ca0ef2eed8bc3a855c Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 21:57:47 -0500 Subject: [PATCH 2/8] Ensure console output is fully buffered --- src/AppInstallerCLICore/TableOutput.h | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index f982d3b116..03a3e1bfe4 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -20,8 +20,8 @@ namespace AppInstaller::CLI::Execution using header_t = std::array; using line_t = std::array; - TableOutput(Reporter& reporter, header_t&& header, size_t sizingBuffer = 50) : - m_reporter(reporter), m_sizingBuffer(sizingBuffer), + TableOutput(Reporter& reporter, header_t&& header) : + m_reporter(reporter), m_hasConsole(GetConsoleWidth().has_value()) { for (size_t i = 0; i < FieldCount; ++i) @@ -36,19 +36,11 @@ namespace AppInstaller::CLI::Execution { m_empty = false; - // When a console is present, stream rows beyond the sizing buffer directly to avoid - // holding all data in memory. When there is no console (redirected output), buffer - // every row so that column widths are computed from the full dataset before any - // output is written, guaranteeing perfect alignment with no truncation. - if (m_hasConsole && m_buffer.size() >= m_sizingBuffer) - { - EvaluateAndFlushBuffer(); - OutputLineToStream(line); - } - else - { - m_buffer.emplace_back(std::move(line)); - } + // Always buffer every row so that column widths are computed from the full dataset + // before any output is written. This guarantees that the widest value in any column + // is always fully visible and columns are perfectly aligned, whether output goes to + // a console or is redirected. Complete() triggers the actual output. + m_buffer.emplace_back(std::move(line)); } void Complete() @@ -76,7 +68,6 @@ namespace AppInstaller::CLI::Execution Reporter& m_reporter; std::array m_columns; - size_t m_sizingBuffer; std::vector m_buffer; bool m_bufferEvaluated = false; bool m_empty = true; From 41d0a0343ef75b85934ee7471f62541971d744b3 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 22:07:37 -0500 Subject: [PATCH 3/8] Suppress visuals when there is no console --- src/AppInstallerCLICore/ExecutionReporter.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index ab5c1d259c..16222345f9 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -56,9 +56,15 @@ namespace AppInstaller::CLI::Execution m_out(outStream), m_in(inStream) { - auto sixelSupported = [&]() { return SixelsSupported(); }; - m_spinner = IIndefiniteSpinner::CreateForStyle(*m_out, ConsoleModeRestore::Instance().IsVTEnabled(), VisualStyle::Accent, sixelSupported); - m_progressBar = IProgressBar::CreateForStyle(*m_out, ConsoleModeRestore::Instance().IsVTEnabled(), VisualStyle::Accent, sixelSupported); + // Only create spinner and progress bar when stdout is attached to a console. + // When output is redirected to a file or pipe, suppress all progress output + // so it does not appear in the redirected stream. + if (GetConsoleWidth().has_value()) + { + auto sixelSupported = [&]() { return SixelsSupported(); }; + m_spinner = IIndefiniteSpinner::CreateForStyle(*m_out, ConsoleModeRestore::Instance().IsVTEnabled(), VisualStyle::Accent, sixelSupported); + m_progressBar = IProgressBar::CreateForStyle(*m_out, ConsoleModeRestore::Instance().IsVTEnabled(), VisualStyle::Accent, sixelSupported); + } SetProgressSink(this); } @@ -146,7 +152,7 @@ namespace AppInstaller::CLI::Execution { m_style = style; - if (m_channel == Channel::Output) + if (m_channel == Channel::Output && GetConsoleWidth().has_value()) { auto sixelSupported = [&]() { return SixelsSupported(); }; m_spinner = IIndefiniteSpinner::CreateForStyle(*m_out, ConsoleModeRestore::Instance().IsVTEnabled(), style, sixelSupported); From 103fc0e2fb06694d5f36dc74699552b9ff856134 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 22:16:12 -0500 Subject: [PATCH 4/8] Release Notes --- doc/ReleaseNotes.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 6b2029d84d..3793f7c768 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -47,6 +47,11 @@ The WinGet MCP server's existing tools have been extended with new parameters to The PowerShell module now automatically uses `GH_TOKEN` or `GITHUB_TOKEN` environment variables to authenticate GitHub API requests. This significantly increases the GitHub API rate limit, preventing failures in CI/CD pipelines. Use `-Verbose` to see which token is being used. +### Improved `list` output when redirected + +- `winget list` (and similar table commands) no longer truncates output when stdout is redirected to a file or variable — column widths are now computed from the full result set. +- Spinner and progress bar output are suppressed when no console is attached, keeping redirected output clean. + ## Bug Fixes * Fixed the `useLatest` property in the DSC v3 `Microsoft.WinGet/Package` resource schema to emit a boolean default (`false`) instead of the incorrect string `"false"`. From 8626ef89d7bf6eaf4a45ea23ceb6f5316dd0fc33 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 22:37:29 -0500 Subject: [PATCH 5/8] Add a few tests --- .../AppInstallerCLITests.vcxproj | 1 + .../AppInstallerCLITests.vcxproj.filters | 3 + src/AppInstallerCLITests/TableOutput.cpp | 116 ++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 src/AppInstallerCLITests/TableOutput.cpp diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj index 387398354d..f87f626911 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -326,6 +326,7 @@ + diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters index 39ecacf9be..a6e9192a24 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -230,6 +230,9 @@ Source Files\Common + + Source Files\Common + Source Files diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp new file mode 100644 index 0000000000..9f043b14cf --- /dev/null +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TestCommon.h" +#include +#include +#include + +using namespace AppInstaller::CLI; +using namespace AppInstaller::CLI::Execution; +using namespace AppInstaller::Utility; + +namespace +{ + Resource::LocString MakeHeader(std::string text) + { + return Resource::LocString{ LocIndString{ std::move(text) } }; + } +} + +// Test that all rows are buffered and column widths account for values beyond the first 50 rows. +// In the old sizing-buffer design, a row at position 55 with a longer value than any of the +// first 50 rows would be truncated. The new design buffers every row so no value is clipped. +TEST_CASE("TableOutput_AllRowsBuffered_NoTruncation", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + Reporter reporter(output, input); + + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); + + // 54 rows with a short first-column value + for (int i = 0; i < 54; ++i) + { + table.OutputLine({ "ShortValue", "id" + std::to_string(i) }); + } + + // Row 55: first column is much longer than any previous row + std::string longValue(50, 'A'); + table.OutputLine({ longValue, "id54" }); + + table.Complete(); + + std::string result = output.str(); + + // The full long value must appear; the ellipsis character (U+2026, UTF-8: E2 80 A6) + // must not appear anywhere, confirming no truncation occurred. + REQUIRE(result.find(longValue) != std::string::npos); + REQUIRE(result.find("\xE2\x80\xA6") == std::string::npos); +} + +// Test that every data row has its second column starting at the same horizontal position, +// i.e., column 1 is consistently padded regardless of the individual value lengths. +TEST_CASE("TableOutput_ColumnsAligned", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + Reporter reporter(output, input); + + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); + + table.OutputLine({ "Short", "id.short" }); + table.OutputLine({ "MediumValue", "id.medium" }); + table.OutputLine({ "LongerValueHere","id.longer" }); + + table.Complete(); + + std::string result = output.str(); + std::istringstream ss(result); + std::vector lines; + std::string line; + while (std::getline(ss, line)) + { + if (!line.empty()) lines.push_back(line); + } + + // Expect: header, separator, 3 data rows = 5 lines minimum + REQUIRE(lines.size() >= 5); + + // The "id." prefix of the second-column value should start at the same position in every data row. + size_t pos0 = lines[2].find("id."); + size_t pos1 = lines[3].find("id."); + size_t pos2 = lines[4].find("id."); + + REQUIRE(pos0 != std::string::npos); + REQUIRE(pos0 == pos1); + REQUIRE(pos0 == pos2); +} + +// Test that calling Complete() on an empty table produces no output. +TEST_CASE("TableOutput_Empty_ProducesNoOutput", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + Reporter reporter(output, input); + + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); + + REQUIRE(table.IsEmpty()); + table.Complete(); + REQUIRE(output.str().empty()); +} + +// Test that GetConsoleWidth does not throw and returns a sensible result. +// In test runner contexts (CI, test explorer) stdout is typically redirected, +// so nullopt is the expected value; when run interactively nullopt or a positive +// width are both valid. +TEST_CASE("GetConsoleWidth_DoesNotCrash", "[channelstreams]") +{ + auto width = GetConsoleWidth(); + if (width.has_value()) + { + REQUIRE(*width > 0); + } + // nullopt is also valid when stdout is not attached to a console +} From 695a21eb1e9e63fe477b5b14ce8f1ef55faad2cc Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 23:01:40 -0500 Subject: [PATCH 6/8] Add test override for actually testing console width --- src/AppInstallerCLICore/ChannelStreams.cpp | 15 +++++ src/AppInstallerCLICore/ChannelStreams.h | 7 +++ src/AppInstallerCLITests/TableOutput.cpp | 73 ++++++++++++++++++---- src/AppInstallerCLITests/TestHooks.h | 23 +++++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/AppInstallerCLICore/ChannelStreams.cpp b/src/AppInstallerCLICore/ChannelStreams.cpp index 39e0fa33f3..7095df2fd3 100644 --- a/src/AppInstallerCLICore/ChannelStreams.cpp +++ b/src/AppInstallerCLICore/ChannelStreams.cpp @@ -8,8 +8,23 @@ namespace AppInstaller::CLI::Execution { using namespace VirtualTerminal; +#ifndef AICLI_DISABLE_TEST_HOOKS + static std::optional* s_consoleWidthOverride = nullptr; + + void TestHook_SetConsoleWidth_Override(std::optional* value) + { + s_consoleWidthOverride = value; + } +#endif + std::optional GetConsoleWidth() { +#ifndef AICLI_DISABLE_TEST_HOOKS + if (s_consoleWidthOverride) + { + return *s_consoleWidthOverride; + } +#endif CONSOLE_SCREEN_BUFFER_INFO consoleInfo{}; if (GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &consoleInfo)) { diff --git a/src/AppInstallerCLICore/ChannelStreams.h b/src/AppInstallerCLICore/ChannelStreams.h index 8e168e380e..16312be88e 100644 --- a/src/AppInstallerCLICore/ChannelStreams.h +++ b/src/AppInstallerCLICore/ChannelStreams.h @@ -16,6 +16,13 @@ namespace AppInstaller::CLI::Execution // (e.g. redirected to a file or pipe). Callers that receive nullopt should not truncate output. std::optional GetConsoleWidth(); +#ifndef AICLI_DISABLE_TEST_HOOKS + // Overrides the value returned by GetConsoleWidth(). Pass nullptr to remove the override. + // Pass a pointer to std::nullopt to simulate no console; pass a pointer to a value to + // simulate a console of that width. + void TestHook_SetConsoleWidth_Override(std::optional* value); +#endif + // The base stream for all channels. struct BaseStream { diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index 9f043b14cf..f7173ec958 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "TestCommon.h" +#include "TestHooks.h" #include #include #include @@ -101,16 +102,66 @@ TEST_CASE("TableOutput_Empty_ProducesNoOutput", "[tableoutput]") REQUIRE(output.str().empty()); } -// Test that GetConsoleWidth does not throw and returns a sensible result. -// In test runner contexts (CI, test explorer) stdout is typically redirected, -// so nullopt is the expected value; when run interactively nullopt or a positive -// width are both valid. -TEST_CASE("GetConsoleWidth_DoesNotCrash", "[channelstreams]") +// Test that the console width override works and that TableOutput truncates the widest column +// (with ellipsis) when the console is too narrow to fit all columns. +TEST_CASE("TableOutput_ConsoleWidth_TruncatesWhenNarrow", "[tableoutput]") { - auto width = GetConsoleWidth(); - if (width.has_value()) - { - REQUIRE(*width > 0); - } - // nullopt is also valid when stdout is not attached to a console + std::ostringstream output; + std::istringstream input; + + // Simulate a console that is too narrow for the content. + // Column 0 max = 20 ("VeryLongPackageName0"), column 1 max = 6 ("pkg.id"). + // SpaceAfter on col 0 = true -> totalRequired = 20 + 1 + 6 = 27. + // With width = 20, extra = (27 - 20) + 1 = 8, so col 0 shrinks to 12. + // "VeryLongPackageName0" (20 chars) > 12 -> ellipsis in output. + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{20} }; + + Reporter reporter(output, input); + + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); + table.OutputLine({ "VeryLongPackageName0", "pkg.id" }); + table.Complete(); + + std::string result = output.str(); + REQUIRE(result.find("\xE2\x80\xA6") != std::string::npos); // ellipsis present = truncation occurred +} + +// Test that with a wide enough console, values are output in full (no ellipsis). +TEST_CASE("TableOutput_ConsoleWidth_NoTruncationWhenWide", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{200} }; + + Reporter reporter(output, input); + + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); + std::string longValue(50, 'A'); + table.OutputLine({ longValue, "pkg.id" }); + table.Complete(); + + std::string result = output.str(); + REQUIRE(result.find(longValue) != std::string::npos); + REQUIRE(result.find("\xE2\x80\xA6") == std::string::npos); +} + +// Test that with no-console override (nullopt), content is never truncated regardless of length. +TEST_CASE("TableOutput_NoConsoleOverride_NeverTruncates", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + + TestHook::SetConsoleWidth_Override widthOverride{ std::nullopt }; // simulate redirected output + + Reporter reporter(output, input); + + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); + std::string longValue(200, 'B'); + table.OutputLine({ longValue, "pkg.id" }); + table.Complete(); + + std::string result = output.str(); + REQUIRE(result.find(longValue) != std::string::npos); + REQUIRE(result.find("\xE2\x80\xA6") == std::string::npos); } diff --git a/src/AppInstallerCLITests/TestHooks.h b/src/AppInstallerCLITests/TestHooks.h index 44c5b2304a..ed0f23ef9c 100644 --- a/src/AppInstallerCLITests/TestHooks.h +++ b/src/AppInstallerCLITests/TestHooks.h @@ -75,6 +75,11 @@ namespace AppInstaller void TestHook_SetScanArchiveResult_Override(bool* status); } + namespace CLI::Execution + { + void TestHook_SetConsoleWidth_Override(std::optional* value); + } + namespace CLI::Workflow { void TestHook_SetEnableWindowsFeatureResult_Override(std::optional&& result); @@ -346,6 +351,24 @@ namespace TestHook std::optional info)> m_downloadFunction; }; + struct SetConsoleWidth_Override + { + // Pass std::nullopt to simulate no console (redirected output); + // pass a size_t value to simulate a console of that width. + SetConsoleWidth_Override(std::optional width) : m_width(width) + { + AppInstaller::CLI::Execution::TestHook_SetConsoleWidth_Override(&m_width); + } + + ~SetConsoleWidth_Override() + { + AppInstaller::CLI::Execution::TestHook_SetConsoleWidth_Override(nullptr); + } + + private: + std::optional m_width; + }; + struct SetGetFontRegistryRoot_Override { SetGetFontRegistryRoot_Override(std::function function) From 9f3197a9946c190392f6fd2eddc83bcb7b3fddb4 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 23:11:54 -0500 Subject: [PATCH 7/8] Add test for extremely large buffer --- src/AppInstallerCLITests/TableOutput.cpp | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index f7173ec958..966aa4a84a 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -165,3 +165,47 @@ TEST_CASE("TableOutput_NoConsoleOverride_NeverTruncates", "[tableoutput]") REQUIRE(result.find(longValue) != std::string::npos); REQUIRE(result.find("\xE2\x80\xA6") == std::string::npos); } + +// Test that a large number of rows can be buffered and output in a single table without +// any truncation or missing rows. +TEST_CASE("TableOutput_ManyRowsBuffered", "[tableoutput]") +{ + // At the time of creating this test, there were ~12k unique package IDs in the community repository + constexpr size_t RowCount = 25000; + std::ostringstream output; + std::istringstream input; + + TestHook::SetConsoleWidth_Override widthOverride{ std::nullopt }; // no console: no truncation + + Reporter reporter(output, input); + + TableOutput<3> table(reporter, { MakeHeader("Name"), MakeHeader("Id"), MakeHeader("Version") }); + + for (size_t i = 0; i < RowCount; ++i) + { + table.OutputLine({ + "Package.Name." + std::to_string(i), + "pkg.id." + std::to_string(i), + "1.0." + std::to_string(i) + }); + } + + table.Complete(); + + REQUIRE_FALSE(table.IsEmpty()); + + std::string result = output.str(); + + // Spot-check first, middle, and last rows + REQUIRE_FALSE(result.empty()); + REQUIRE(result.find("pkg.id.0") != std::string::npos); + REQUIRE(result.find("pkg.id." + std::to_string(RowCount / 2)) != std::string::npos); + REQUIRE(result.find("pkg.id." + std::to_string(RowCount - 1)) != std::string::npos); + + // Count newlines to verify all rows were written (header + separator + RowCount data rows) + size_t lineCount = std::count(result.begin(), result.end(), '\n'); + REQUIRE(lineCount == RowCount + 2); // +2 for header and separator lines + + // No truncation ellipsis anywhere + REQUIRE(result.find("\xE2\x80\xA6") == std::string::npos); +} From 28903b476d9e204301129087a21be519d49dfa93 Mon Sep 17 00:00:00 2001 From: Kaleb Luedtke Date: Mon, 13 Apr 2026 23:28:00 -0500 Subject: [PATCH 8/8] Spelling --- .github/actions/spelling/expect.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 72aba15720..0de3433dcc 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -561,6 +561,7 @@ swgus SYD SYG systemnotsupported +tableoutput Tagit TARG taskhostw