@samitouri / QOSAMI-WSL / commits / 8e3c0ad8

CLI: Add Reporter for structured CLI output with VT color and level routing (#40798)

* Reporter unified CLI output

David Bennett committed Jun 17, 2026 at 12:24 UTC 8e3c0ad8b71714a737d165a6160a892714c2b4c8
11 files changed +807 -7
src/windows/common/VTSupport.cpp
+7
@@ -441,6 +441,13 @@ namespace Format {
441 << WSL_WINDOWS_VT_ESCAPE << L"\\";
442 return ConstructedSequence{std::move(result).str()};
443 }
444 +
445 + ConstructedSequence LinkOpen(const std::wstring& url)
446 + {
447 + return ConstructedSequence{std::format(WSL_WINDOWS_VT_OSC L"8;;{}" WSL_WINDOWS_VT_ESCAPE L"\\", url)};
448 + }
449 +
450 + const Sequence LinkClose{WSL_WINDOWS_VT_OSC L"8;;" WSL_WINDOWS_VT_ESCAPE L"\\"};
451 } // namespace Format
452
453 namespace Erase {
src/windows/common/VTSupport.h
+2
@@ -311,6 +311,8 @@ namespace Format {
311 } // namespace Bg
312
313 ConstructedSequence Hyperlink(const std::wstring& text, const std::wstring& ref);
314 + ConstructedSequence LinkOpen(const std::wstring& url);
315 + extern const Sequence LinkClose;
316 } // namespace Format
317
318 // Line and screen erasure sequences.
src/windows/wslc/core/CLIExecutionContext.cpp
+5 -1
@@ -4,6 +4,7 @@ Copyright (c) Microsoft. All rights reserved.
4
5 --*/
6 #include "precomp.h"
7 +#include "Argument.h"
8 #include "CLIExecutionContext.h"
9
10 namespace wsl::windows::wslc::execution {
@@ -18,7 +19,10 @@ HANDLE CLIExecutionContext::CreateCancelEvent()
19 // This method should be idempotent.
20 void CLIExecutionContext::ApplyGlobalOptions()
21 {
21 - // TODO: Add per-global side effects here as features land.
22 + if (GlobalArgs.Contains(ArgType::NoColor))
23 + {
24 + Reporter.SetNoColor(true);
25 + }
26 }
27
28 } // namespace wsl::windows::wslc::execution
src/windows/wslc/core/CLIExecutionContext.h
+5 -2
@@ -14,6 +14,7 @@ Abstract:
14 #pragma once
15 #include "ArgumentTypes.h"
16 #include "ExecutionContextData.h"
17 +#include "Reporter.h"
18 #include <optional>
19
20 namespace wsl::windows::wslc::execution {
@@ -26,8 +27,7 @@ struct CLIExecutionContext : public wsl::windows::common::ExecutionContext
27 ~CLIExecutionContext() override = default;
28
29 NON_COPYABLE(CLIExecutionContext);
29 - CLIExecutionContext(CLIExecutionContext&&) = default;
30 - CLIExecutionContext& operator=(CLIExecutionContext&&) = default;
30 + NON_MOVABLE(CLIExecutionContext);
31
32 // Per-subcommand arguments parsed by the resolved leaf Command.
33 argument::ArgMap Args;
@@ -39,6 +39,9 @@ struct CLIExecutionContext : public wsl::windows::common::ExecutionContext
39 // Map of data stored in the context.
40 DataMap Data;
41
42 + // Central output reporter for all user-facing status messages.
43 + Reporter Reporter;
44 +
45 // Process exit code set by tasks like Run/Exec.
46 std::optional<int> ExitCode;
47
src/windows/wslc/core/Main.cpp
+17 -4
@@ -127,25 +127,38 @@ try
127 catch (...)
128 {
129 LOG_CAUGHT_EXCEPTION();
130 - result = wil::ResultFromCaughtException();
130
131 + // If the user pressed Ctrl-C, acknowledge the cancellation and exit.
132 if (context.CancelEvent && context.CancelEvent.is_signaled())
133 {
134 - fwprintf(stderr, L"\nCancelled.\n");
134 + // Cancel events are often considered warnings rather than errors, as the user
135 + // intentionally triggered it.
136 + const auto strings = wslutil::ErrorToString({.Code = HRESULT_FROM_WIN32(ERROR_CANCELLED)});
137 + context.Reporter.Warn(L"\n{}\n", strings.Message);
138 +
139 + // Exit with code 1 is consistent with Docker build and pull, but the POSIX-convention
140 + // for cancellation is exit code 130, which is used by Docker compose and most shells.
141 + // TODO: Consider switching to 130 or differentiate the cancellation types when we have
142 + // more than image cancellation supported.
143 return 1;
144 }
145
146 + // Using WSL shared utility to get the HRESULT from the caught exception.
147 + // CLIExecutionContext is a derived class of wsl::windows::common::ExecutionContext.
148 + result = wil::ResultFromCaughtException();
149 +
150 if (FAILED(result))
151 {
152 if (const auto& reported = context.ReportedError())
153 {
154 auto strings = wslutil::ErrorToString(*reported);
155 auto errorMessage = strings.Message.empty() ? strings.Code : strings.Message;
144 - wslutil::PrintMessage(Localization::MessageErrorCode(errorMessage, wslutil::ErrorCodeToString(result)), stderr);
156 + context.Reporter.Error(L"{}\n", Localization::MessageErrorCode(errorMessage, wslutil::ErrorCodeToString(result)));
157 }
158 else
159 {
148 - wslutil::PrintMessage(Localization::MessageErrorCode("", wslutil::ErrorCodeToString(result)), stderr);
160 + // Fallback for errors without context
161 + context.Reporter.Error(L"{}\n", Localization::MessageErrorCode(L"", wslutil::ErrorCodeToString(result)));
162 }
163 }
164 }
src/windows/wslc/core/OutputChannel.cpp new
+91
@@ -0,0 +1,91 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + OutputChannel.cpp
8 +
9 +Abstract:
10 +
11 + Implementation of OutputChannel.
12 +
13 +--*/
14 +#include "precomp.h"
15 +#include "OutputChannel.h"
16 +
17 +#include <algorithm>
18 +#include <cerrno>
19 +
20 +namespace wsl::windows::wslc {
21 +
22 +OutputChannel::OutputChannel(HANDLE consoleHandle, FILE* fallbackFile)
23 +{
24 + DWORD mode = 0;
25 + if (consoleHandle != INVALID_HANDLE_VALUE && consoleHandle != nullptr && GetConsoleMode(consoleHandle, &mode))
26 + {
27 + m_consoleHandle = consoleHandle;
28 + m_vtMode.emplace(consoleHandle);
29 + }
30 + else
31 + {
32 + WI_ASSERT(fallbackFile != nullptr);
33 + m_file = fallbackFile;
34 + }
35 +}
36 +
37 +OutputChannel::OutputChannel(FILE* file, bool vtOverride) : m_file(file), m_vtOverride(vtOverride)
38 +{
39 + WI_ASSERT(file != nullptr);
40 +}
41 +
42 +void OutputChannel::WriteString(std::wstring_view text) const
43 +{
44 + if (text.empty())
45 + {
46 + return;
47 + }
48 +
49 + if (m_consoleHandle != nullptr)
50 + {
51 + DWORD written = 0;
52 + LOG_IF_WIN32_BOOL_FALSE(WriteConsoleW(m_consoleHandle, text.data(), static_cast<DWORD>(text.size()), &written, nullptr));
53 + return;
54 + }
55 +
56 + if (fwprintf(m_file, L"%.*ls", static_cast<int>(text.size()), text.data()) < 0)
57 + {
58 + const int err = errno;
59 + LOG_HR_MSG(HRESULT_FROM_WIN32(ERROR_WRITE_FAULT), "fwprintf to redirected output failed (errno=%d)", err);
60 + }
61 +}
62 +
63 +std::optional<int> OutputChannel::GetConsoleWidth() const
64 +{
65 + if (m_consoleHandle == nullptr)
66 + {
67 + return std::nullopt;
68 + }
69 +
70 + CONSOLE_SCREEN_BUFFER_INFO info{};
71 + if (!GetConsoleScreenBufferInfo(m_consoleHandle, &info))
72 + {
73 + return std::nullopt;
74 + }
75 +
76 + // (Right - Left + 1) is the visible width; subtract one more as an autowrap guard.
77 + return std::max(0, static_cast<int>(info.srWindow.Right) - static_cast<int>(info.srWindow.Left));
78 +}
79 +
80 +bool OutputChannel::IsVTEnabled() const noexcept
81 +{
82 + if (m_consoleHandle != nullptr)
83 + {
84 + DWORD mode = 0;
85 + return GetConsoleMode(m_consoleHandle, &mode) && WI_IsFlagSet(mode, ENABLE_VIRTUAL_TERMINAL_PROCESSING);
86 + }
87 +
88 + return m_vtOverride;
89 +}
90 +
91 +} // namespace wsl::windows::wslc
src/windows/wslc/core/OutputChannel.h new
+53
@@ -0,0 +1,53 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + OutputChannel.h
8 +
9 +Abstract:
10 +
11 + Byte sink used by Reporter. Each WriteString call is a single WriteConsoleW
12 + or fwprintf. For console destinations the channel owns VT enablement (RAII).
13 +
14 +--*/
15 +#pragma once
16 +
17 +#include "VTSupport.h"
18 +#include "defs.h"
19 +
20 +#include <cstdio>
21 +#include <optional>
22 +#include <string_view>
23 +#include <Windows.h>
24 +
25 +namespace wsl::windows::wslc {
26 +
27 +class OutputChannel
28 +{
29 +public:
30 + NON_COPYABLE(OutputChannel);
31 + NON_MOVABLE(OutputChannel);
32 +
33 + // Console path: probes handle, enables VT; falls back to fallbackFile when redirected.
34 + OutputChannel(HANDLE consoleHandle, FILE* fallbackFile);
35 +
36 + // FILE* path with explicit VT override (for tests).
37 + OutputChannel(FILE* file, bool vtOverride);
38 +
39 + void WriteString(std::wstring_view text) const;
40 +
41 + // Console write width minus one (autowrap guard), or nullopt when redirected.
42 + std::optional<int> GetConsoleWidth() const;
43 +
44 + bool IsVTEnabled() const noexcept;
45 +
46 +private:
47 + HANDLE m_consoleHandle = nullptr;
48 + FILE* m_file = nullptr;
49 + bool m_vtOverride = false;
50 + std::optional<wsl::windows::common::vt::EnableVirtualTerminal> m_vtMode;
51 +};
52 +
53 +} // namespace wsl::windows::wslc
src/windows/wslc/core/Reporter.cpp new
+63
@@ -0,0 +1,63 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + Reporter.cpp
8 +
9 +Abstract:
10 +
11 + Implementation of Reporter.
12 +
13 +--*/
14 +#include "precomp.h"
15 +#include "Reporter.h"
16 +
17 +namespace wsl::windows::wslc {
18 +
19 +using namespace wsl::windows::common::vt;
20 +
21 +Reporter::Reporter() : m_out(GetStdHandle(STD_OUTPUT_HANDLE), stdout), m_err(GetStdHandle(STD_ERROR_HANDLE), stderr)
22 +{
23 +}
24 +
25 +Reporter::Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled) :
26 + m_out(outFile, outVtEnabled), m_err(errFile, errVtEnabled)
27 +{
28 +}
29 +
30 +std::wstring_view Reporter::LevelPrefix(Level level) const noexcept
31 +{
32 + if (!IsColorEnabled(level))
33 + {
34 + return {};
35 + }
36 +
37 + switch (level)
38 + {
39 + case Level::Warning:
40 + return Format::Fg::BrightYellow.Get();
41 + case Level::Error:
42 + return Format::Fg::BrightRed.Get();
43 + default:
44 + return {};
45 + }
46 +}
47 +
48 +bool Reporter::IsVTEnabled(Level level) const noexcept
49 +{
50 + return ChannelFor(level).IsVTEnabled();
51 +}
52 +
53 +bool Reporter::IsColorEnabled(Level level) const noexcept
54 +{
55 + return ChannelFor(level).IsVTEnabled() && !m_noColor;
56 +}
57 +
58 +std::optional<int> Reporter::GetConsoleWidth(Level level) const
59 +{
60 + return ChannelFor(level).GetConsoleWidth();
61 +}
62 +
63 +} // namespace wsl::windows::wslc
src/windows/wslc/core/Reporter.h new
+165
@@ -0,0 +1,165 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + Reporter.h
8 +
9 +Abstract:
10 +
11 + Level-filtered, std::format-style user-facing output for the WSLC CLI.
12 + Sequence arguments are stripped when VT is off; color Sequences are also
13 + stripped when color is disabled, while cursor-move Sequences still pass
14 + through.
15 +
16 +--*/
17 +#pragma once
18 +
19 +#include "OutputChannel.h"
20 +#include "VTSupport.h"
21 +
22 +#include <cstdio>
23 +#include <format>
24 +#include <optional>
25 +#include <string>
26 +#include <string_view>
27 +#include <tuple>
28 +#include <type_traits>
29 +#include <utility>
30 +
31 +namespace wsl::windows::wslc {
32 +
33 +namespace reporter_detail {
34 +
35 + // SFINAE: excludes Sequence-derived types so the overload below wins for them.
36 + template <typename T, typename = std::enable_if_t<!std::is_base_of_v<wsl::windows::common::vt::Sequence, std::remove_cvref_t<T>>>>
37 + constexpr T&& StripIfDisabled(T&& value, bool /*vtEnabled*/, bool /*colorEnabled*/) noexcept
38 + {
39 + return std::forward<T>(value);
40 + }
41 +
42 + // Returns VT bytes when permitted, empty when stripped. The returned view borrows
43 + // from the caller's argument, which outlives the Write call.
44 + inline std::wstring_view StripIfDisabled(const wsl::windows::common::vt::Sequence& sequence, bool vtEnabled, bool colorEnabled)
45 + {
46 + if (!vtEnabled)
47 + {
48 + return {};
49 + }
50 + if (!colorEnabled && sequence.IsColor())
51 + {
52 + return {};
53 + }
54 + return sequence.Get();
55 + }
56 +
57 +} // namespace reporter_detail
58 +
59 +struct Reporter
60 +{
61 + enum class Level
62 + {
63 + Output,
64 + Info,
65 + Warning,
66 + Error,
67 + };
68 +
69 + Reporter();
70 + Reporter(FILE* outFile, bool outVtEnabled, FILE* errFile, bool errVtEnabled);
71 +
72 + NON_COPYABLE(Reporter);
73 + NON_MOVABLE(Reporter);
74 +
75 + ~Reporter() = default;
76 +
77 + // std::format-style write API.
78 + template <typename... Args>
79 + void Write(Level level, std::wformat_string<Args...> fmt, Args&&... args)
80 + {
81 + EmitFormatted(level, std::move(fmt), std::forward<Args>(args)...);
82 + }
83 +
84 + template <typename... Args>
85 + void Output(std::wformat_string<Args...> fmt, Args&&... args)
86 + {
87 + EmitFormatted(Level::Output, std::move(fmt), std::forward<Args>(args)...);
88 + }
89 + template <typename... Args>
90 + void Info(std::wformat_string<Args...> fmt, Args&&... args)
91 + {
92 + EmitFormatted(Level::Info, std::move(fmt), std::forward<Args>(args)...);
93 + }
94 + template <typename... Args>
95 + void Warn(std::wformat_string<Args...> fmt, Args&&... args)
96 + {
97 + EmitFormatted(Level::Warning, std::move(fmt), std::forward<Args>(args)...);
98 + }
99 + template <typename... Args>
100 + void Error(std::wformat_string<Args...> fmt, Args&&... args)
101 + {
102 + EmitFormatted(Level::Error, std::move(fmt), std::forward<Args>(args)...);
103 + }
104 +
105 + bool IsVTEnabled(Level level) const noexcept;
106 +
107 + bool IsColorEnabled(Level level) const noexcept;
108 +
109 + bool IsNoColor() const noexcept
110 + {
111 + return m_noColor;
112 + }
113 +
114 + void SetNoColor(bool noColor) noexcept
115 + {
116 + m_noColor = noColor;
117 + }
118 +
119 + // Console write width minus one (autowrap guard), or nullopt when redirected.
120 + std::optional<int> GetConsoleWidth(Level level) const;
121 +
122 +private:
123 + const OutputChannel& ChannelFor(Level level) const noexcept
124 + {
125 + return (level == Level::Output) ? m_out : m_err;
126 + }
127 +
128 + // Per-level SGR prefix (empty when color is off).
129 + std::wstring_view LevelPrefix(Level level) const noexcept;
130 +
131 + template <typename... Args>
132 + void EmitFormatted(Level level, std::wformat_string<Args...> fmt, Args&&... args)
133 + {
134 + const OutputChannel& channel = ChannelFor(level);
135 + const bool vtEnabled = channel.IsVTEnabled();
136 + const bool colorEnabled = vtEnabled && !m_noColor;
137 +
138 + // Materialize stripped args into stable storage for vformat.
139 + auto stripped = std::tuple{reporter_detail::StripIfDisabled(std::forward<Args>(args), vtEnabled, colorEnabled)...};
140 +
141 + std::wstring body = std::apply(
142 + [&fmt](auto&... values) { return std::vformat(std::wstring_view{fmt.get()}, std::make_wformat_args(values...)); }, stripped);
143 +
144 + const auto prefix = LevelPrefix(level);
145 + if (prefix.empty())
146 + {
147 + channel.WriteString(body);
148 + return;
149 + }
150 +
151 + const auto reset = wsl::windows::common::vt::Format::Default.Get();
152 + std::wstring out;
153 + out.reserve(prefix.size() + body.size() + reset.size());
154 + out.append(prefix);
155 + out.append(body);
156 + out.append(reset);
157 + channel.WriteString(out);
158 + }
159 +
160 + OutputChannel m_out;
161 + OutputChannel m_err;
162 + bool m_noColor = false;
163 +};
164 +
165 +} // namespace wsl::windows::wslc
test/windows/wslc/WSLCCLIReporterUnitTests.cpp new
+314
@@ -0,0 +1,314 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + WSLCCLIReporterUnitTests.cpp
8 +
9 +Abstract:
10 +
11 + Unit tests for OutputChannel and Reporter.
12 +
13 +--*/
14 +
15 +#include "precomp.h"
16 +#include "windows/Common.h"
17 +#include "WSLCCLITestHelpers.h"
18 +
19 +#include "OutputChannel.h"
20 +#include "Reporter.h"
21 +
22 +using namespace wsl::windows::wslc;
23 +using namespace wsl::windows::common::vt;
24 +using namespace WSLCTestHelpers;
25 +using namespace WEX::Logging;
26 +using namespace WEX::Common;
27 +using namespace WEX::TestExecution;
28 +
29 +namespace WSLCCLIReporterUnitTests {
30 +
31 +// Dual-pipe Reporter so stdout and stderr can be asserted independently.
32 +struct SplitCaptureReporter
33 +{
34 + CapturePipe outPipe;
35 + CapturePipe errPipe;
36 + Reporter reporter;
37 +
38 + explicit SplitCaptureReporter(bool vtEnabled = false) : reporter(outPipe.file(), vtEnabled, errPipe.file(), vtEnabled)
39 + {
40 + }
41 +};
42 +
43 +class WSLCCLIReporterUnitTests
44 +{
45 + WSLC_TEST_CLASS(WSLCCLIReporterUnitTests)
46 +
47 + TEST_CLASS_SETUP(TestClassSetup)
48 + {
49 + return true;
50 + }
51 +
52 + TEST_CLASS_CLEANUP(TestClassCleanup)
53 + {
54 + return true;
55 + }
56 +
57 + TEST_METHOD(OutputChannel_WriteStringWritesText)
58 + {
59 + CapturePipe pipe;
60 + const OutputChannel channel{pipe.file(), false};
61 + channel.WriteString(L"hello");
62 + VERIFY_ARE_EQUAL(std::wstring{L"hello"}, pipe.captured());
63 + }
64 +
65 + TEST_METHOD(OutputChannel_WriteStringIsNoOpOnEmpty)
66 + {
67 + CapturePipe pipe;
68 + const OutputChannel channel{pipe.file(), false};
69 + channel.WriteString(L"");
70 + VERIFY_ARE_EQUAL(std::wstring{L""}, pipe.captured());
71 + }
72 +
73 + TEST_METHOD(OutputChannel_FromHandleFallsBackToFileForNonConsole)
74 + {
75 + CapturePipe pipe;
76 + const OutputChannel channel{INVALID_HANDLE_VALUE, pipe.file()};
77 + channel.WriteString(L"fallback");
78 + VERIFY_ARE_EQUAL(std::wstring{L"fallback"}, pipe.captured());
79 + VERIFY_IS_FALSE(channel.GetConsoleWidth().has_value());
80 + }
81 +
82 + TEST_METHOD(OutputChannel_GetConsoleWidth_FileChannelReturnsNullopt)
83 + {
84 + CapturePipe pipe;
85 + const OutputChannel channel{pipe.file(), false};
86 + VERIFY_IS_FALSE(channel.GetConsoleWidth().has_value());
87 + }
88 +
89 + TEST_METHOD(Reporter_WriteEmitsExactText)
90 + {
91 + CaptureReporter cap;
92 + cap.reporter.Output(L"hello\n");
93 + VERIFY_ARE_EQUAL(std::wstring{L"hello\n"}, cap.captured());
94 + }
95 +
96 + TEST_METHOD(Reporter_WriteWithoutNewline)
97 + {
98 + CaptureReporter cap;
99 + cap.reporter.Write(Reporter::Level::Output, L"hello");
100 + VERIFY_ARE_EQUAL(std::wstring{L"hello"}, cap.captured());
101 + }
102 +
103 + TEST_METHOD(Reporter_FormatStringSubstitutesArgs)
104 + {
105 + CaptureReporter cap;
106 + cap.reporter.Output(L"value={}, name={}\n", 42, L"alice");
107 + VERIFY_ARE_EQUAL(std::wstring{L"value=42, name=alice\n"}, cap.captured());
108 + }
109 +
110 + TEST_METHOD(Reporter_PlainStringNeedsNoArgs)
111 + {
112 + CaptureReporter cap;
113 + cap.reporter.Output(L"plain literal\n");
114 + VERIFY_ARE_EQUAL(std::wstring{L"plain literal\n"}, cap.captured());
115 + }
116 +
117 + TEST_METHOD(Reporter_SequenceEmittedWhenVTEnabled)
118 + {
119 + CaptureReporter cap{/*vtEnabled*/ true};
120 + cap.reporter.Output(L"{}highlighted{}\n", Format::Fg::BrightYellow, Format::Default);
121 +
122 + const auto result = cap.captured();
123 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(L"highlighted"));
124 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(Format::Fg::BrightYellow.Get()));
125 + }
126 +
127 + TEST_METHOD(Reporter_SequenceStrippedWhenVTDisabled)
128 + {
129 + CaptureReporter cap{/*vtEnabled*/ false};
130 + cap.reporter.Output(L"{}plain{}\n", Format::Fg::BrightYellow, Format::Default);
131 + VERIFY_ARE_EQUAL(std::wstring{L"plain\n"}, cap.captured());
132 + }
133 +
134 + TEST_METHOD(Reporter_ColorSequenceStrippedWhenNoColor)
135 + {
136 + CaptureReporter cap{/*vtEnabled*/ true};
137 + cap.reporter.SetNoColor(true);
138 +
139 + // Color sequence (SGR) stripped; cursor moves (non-color) still pass.
140 + cap.reporter.Output(L"{}{}plain{}\n", Cursor::Up(1), Format::Fg::BrightRed, Format::Default);
141 +
142 + const auto result = cap.captured();
143 + VERIFY_ARE_EQUAL(std::wstring::npos, result.find(Format::Fg::BrightRed.Get()));
144 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(Cursor::Up(1).Get()));
145 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(L"plain"));
146 + }
147 +
148 + TEST_METHOD(Reporter_ConstructedSequenceHandledLikeSequence)
149 + {
150 + CaptureReporter cap{/*vtEnabled*/ true};
151 + const auto cursor = Cursor::Up(3);
152 + cap.reporter.Output(L"{}done\n", cursor);
153 +
154 + const auto result = cap.captured();
155 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(cursor.Get()));
156 + VERIFY_ARE_NOT_EQUAL(std::wstring::npos, result.find(L"done"));
157 + }
158 +
159 + TEST_METHOD(Reporter_LevelColorWrapsOutputWhenVTEnabled)
160 + {
161 + CaptureReporter cap{/*vtEnabled*/ true};
162 +
163 + cap.reporter.Output(L"starting\n");
164 + cap.reporter.Info(L"pulling\n");
165 + cap.reporter.Warn(L"careful\n");
166 + cap.reporter.Error(L"failed\n");
167 +
168 + const std::wstring def{Format::Default.Get()};
169 + const std::wstring yellow{Format::Fg::BrightYellow.Get()};
170 + const std::wstring red{Format::Fg::BrightRed.Get()};
171 +
172 + const auto expected = std::wstring{L"starting\npulling\n"} + yellow + L"careful\n" + def + red + L"failed\n" + def;
173 +
174 + VERIFY_ARE_EQUAL(expected, cap.captured());
175 + }
176 +
177 + TEST_METHOD(Reporter_LevelColorSuppressedWhenVTDisabled)
178 + {
179 + CaptureReporter cap{/*vtEnabled*/ false};
180 + cap.reporter.Error(L"failed\n");
181 + VERIFY_ARE_EQUAL(std::wstring{L"failed\n"}, cap.captured());
182 + }
183 +
184 + TEST_METHOD(Reporter_LevelColorSuppressedWhenNoColor)
185 + {
186 + CaptureReporter cap{/*vtEnabled*/ true};
187 + cap.reporter.SetNoColor(true);
188 + cap.reporter.Warn(L"careful\n");
189 + VERIFY_ARE_EQUAL(std::wstring{L"careful\n"}, cap.captured());
190 + }
191 +
192 + TEST_METHOD(Reporter_RoutingByLevel)
193 + {
194 + SplitCaptureReporter cap;
195 +
196 + cap.reporter.Output(L"output text\n");
197 + cap.reporter.Info(L"info text\n");
198 + cap.reporter.Warn(L"warn text\n");
199 + cap.reporter.Error(L"error text\n");
200 +
201 + VERIFY_ARE_EQUAL(std::wstring{L"output text\n"}, cap.outPipe.captured());
202 + VERIFY_ARE_EQUAL(std::wstring{L"info text\nwarn text\nerror text\n"}, cap.errPipe.captured());
203 + }
204 +
205 + TEST_METHOD(Reporter_SetNoColorTogglesIsNoColor)
206 + {
207 + CaptureReporter cap;
208 + VERIFY_IS_FALSE(cap.reporter.IsNoColor());
209 + cap.reporter.SetNoColor(true);
210 + VERIFY_IS_TRUE(cap.reporter.IsNoColor());
211 + cap.reporter.SetNoColor(false);
212 + VERIFY_IS_FALSE(cap.reporter.IsNoColor());
213 + }
214 +
215 + TEST_METHOD(Reporter_IsVTEnabledReflectsPerChannelState)
216 + {
217 + {
218 + SplitCaptureReporter cap{/*vt*/ false};
219 + VERIFY_IS_FALSE(cap.reporter.IsVTEnabled(Reporter::Level::Output));
220 + VERIFY_IS_FALSE(cap.reporter.IsVTEnabled(Reporter::Level::Error));
221 + }
222 + {
223 + SplitCaptureReporter cap{/*vt*/ true};
224 + VERIFY_IS_TRUE(cap.reporter.IsVTEnabled(Reporter::Level::Output));
225 + VERIFY_IS_TRUE(cap.reporter.IsVTEnabled(Reporter::Level::Error));
226 + }
227 + {
228 + CapturePipe outPipe;
229 + CapturePipe errPipe;
230 + Reporter reporter{outPipe.file(), /*outVt*/ true, errPipe.file(), /*errVt*/ false};
231 + VERIFY_IS_TRUE(reporter.IsVTEnabled(Reporter::Level::Output));
232 + VERIFY_IS_FALSE(reporter.IsVTEnabled(Reporter::Level::Info));
233 + VERIFY_IS_FALSE(reporter.IsVTEnabled(Reporter::Level::Warning));
234 + VERIFY_IS_FALSE(reporter.IsVTEnabled(Reporter::Level::Error));
235 + }
236 + }
237 +
238 + TEST_METHOD(Reporter_IsColorEnabledPerLevelHonorsBothVTAndNoColor)
239 + {
240 + SplitCaptureReporter cap{/*vt*/ true};
241 + VERIFY_IS_TRUE(cap.reporter.IsColorEnabled(Reporter::Level::Output));
242 + VERIFY_IS_TRUE(cap.reporter.IsColorEnabled(Reporter::Level::Error));
243 +
244 + cap.reporter.SetNoColor(true);
245 + VERIFY_IS_FALSE(cap.reporter.IsColorEnabled(Reporter::Level::Output));
246 + VERIFY_IS_FALSE(cap.reporter.IsColorEnabled(Reporter::Level::Error));
247 + }
248 +
249 + TEST_METHOD(Reporter_GetConsoleWidthReturnsNulloptForFileChannels)
250 + {
251 + SplitCaptureReporter cap;
252 + VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Output).has_value());
253 + VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Info).has_value());
254 + VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Warning).has_value());
255 + VERIFY_IS_FALSE(cap.reporter.GetConsoleWidth(Reporter::Level::Error).has_value());
256 + }
257 +
258 + TEST_METHOD(Reporter_Write_MixesSequencesWithStandardFormatArgs)
259 + {
260 + // Reporter.Write is std::format under the hood — any formattable type works
261 + // alongside Sequences. Sequences are stripped when color is off; everything
262 + // else formats normally through std::format machinery.
263 + //
264 + // This test exercises four sequence categories in a single format call:
265 + // SGR color (Format::Fg::BrightRed) — color, stripped by NoColor
266 + // Non-color (Erase::LineForward) — not color, survives NoColor
267 + // Hyperlink (ConstructedSequence OSC8) — color, stripped by NoColor
268 + // SGR reset (Format::Default) — color, stripped by NoColor
269 + //
270 + // Hyperlink open/close are separate sequences so the visible link text
271 + // degrades gracefully when sequences are stripped.
272 +
273 + const auto& eraseLine = Erase::LineForward; // \x1b[K — non-color CSI
274 + const auto linkOpen = Format::LinkOpen(L"https://example.com");
275 + const auto& linkClose = Format::LinkClose;
276 +
277 + // Format: <color>Count: <int>, hex: <hex>, <erase><linkOpen>click here<linkClose><reset>
278 + constexpr auto fmt = L"{}Count: {}, hex: {:04x}, {}{}click here{}{}\n";
279 +
280 + // VT + color enabled: equivalent to std::format with all sequence bytes.
281 + {
282 + CaptureReporter cap{/*vtEnabled*/ true};
283 + cap.reporter.Output(fmt, Format::Fg::BrightRed, 42, 255u, eraseLine, linkOpen, linkClose, Format::Default);
284 +
285 + const auto expected = std::format(
286 + fmt, Format::Fg::BrightRed.Get(), 42, 255u, eraseLine.Get(), linkOpen.Get(), linkClose.Get(), Format::Default.Get());
287 + VERIFY_ARE_EQUAL(expected, cap.captured());
288 + }
289 +
290 + // NoColor (VT enabled, color disabled): non-color sequences pass through,
291 + // color sequences (SGR, hyperlink) replaced with empty string.
292 + {
293 + CaptureReporter cap{/*vtEnabled*/ true};
294 + cap.reporter.SetNoColor(true);
295 + cap.reporter.Output(fmt, Format::Fg::BrightRed, 42, 255u, eraseLine, linkOpen, linkClose, Format::Default);
296 +
297 + const std::wstring_view empty;
298 + const auto expected = std::format(fmt, empty, 42, 255u, eraseLine.Get(), empty, empty, empty);
299 + VERIFY_ARE_EQUAL(expected, cap.captured());
300 + }
301 +
302 + // VT disabled: all sequences replaced with empty string.
303 + {
304 + CaptureReporter cap{/*vtEnabled*/ false};
305 + cap.reporter.Output(fmt, Format::Fg::BrightRed, 42, 255u, eraseLine, linkOpen, linkClose, Format::Default);
306 +
307 + const std::wstring_view empty;
308 + const auto expected = std::format(fmt, empty, 42, 255u, empty, empty, empty, empty);
309 + VERIFY_ARE_EQUAL(expected, cap.captured());
310 + }
311 + }
312 +};
313 +
314 +} // namespace WSLCCLIReporterUnitTests
test/windows/wslc/WSLCCLITestHelpers.h
+85
@@ -14,10 +14,20 @@ Abstract:
14
15 #pragma once
16
17 +#include <fcntl.h>
18 +#include <io.h>
19 +#include <algorithm>
20 +#include <memory>
21 #include <string>
22 +#include <vector>
23 #include <Windows.h>
24 #include <WexTestClass.h>
25 +#include <wil/resource.h>
26 +#include <wslutil.h>
27 +#include "windows/Common.h"
28 #include "Invocation.h"
29 +#include "OutputChannel.h"
30 +#include "Reporter.h"
31 #include "TableOutput.h"
32
33 namespace WSLCTestHelpers {
@@ -62,6 +72,81 @@ inline void LogComment(const std::wstring& message)
72 WEX::Logging::Log::Comment(reinterpret_cast<const char8_t*>(WStringToUTF8(message).c_str()));
73 }
74
75 +// RAII pipe pair for capturing FILE* output in tests.
76 +// file() is passed to OutputChannel/Reporter; captured() drains the read end after flush.
77 +struct CapturePipe
78 +{
79 + CapturePipe()
80 + {
81 + // ReadPipeOverlapped=true so PartialHandleRead's InterruptableRead can be
82 + // interrupted by m_exitEvent during teardown if fclose hasn't run yet.
83 + auto [r, w] = wsl::windows::common::wslutil::OpenAnonymousPipe(0, true, false);
84 + wil::unique_handle writeHandle{w.release()};
85 + m_file = FileFromHandle(writeHandle, "w");
86 +
87 + const int fd = _fileno(m_file.get());
88 + WI_VERIFY(_setmode(fd, _O_U8TEXT) != -1);
89 +
90 + // Disable CRT buffering so each fwprintf is a single write. Prevents
91 + // _O_U8TEXT from splitting VT escape sequences across buffer flushes.
92 + setvbuf(m_file.get(), nullptr, _IONBF, 0);
93 +
94 + // CapturePipe owns the read pipe; PartialHandleRead borrows it via .get().
95 + // m_readPipe is declared before m_reader so destruction order tears the reader
96 + // down first (joining its thread) and only then closes the handle it was reading.
97 + m_readPipe = std::move(r);
98 + m_reader = std::make_unique<PartialHandleRead>(m_readPipe.get());
99 + }
100 +
101 + NON_COPYABLE(CapturePipe);
102 + NON_MOVABLE(CapturePipe);
103 +
104 + FILE* file() const
105 + {
106 + return m_file.get();
107 + }
108 +
109 + std::wstring captured()
110 + {
111 + m_file.reset();
112 +
113 + m_reader->ExpectClosed();
114 + std::wstring result = wsl::shared::string::MultiByteToWide(m_reader->GetData());
115 +
116 + // _O_U8TEXT prepends a UTF-8 BOM on some streams; strip it if present.
117 + if (!result.empty() && result[0] == L'\xFEFF')
118 + {
119 + result.erase(0, 1);
120 + }
121 +
122 + // _O_U8TEXT translates \n to \r\n; strip \r so tests compare plain newlines.
123 + result.erase(std::remove(result.begin(), result.end(), L'\r'), result.end());
124 + return result;
125 + }
126 +
127 +private:
128 + wil::unique_file m_file;
129 + wil::unique_hfile m_readPipe;
130 + std::unique_ptr<PartialHandleRead> m_reader;
131 +};
132 +
133 +// Reporter wired to a single capture pipe for full output capture.
134 +// VT is disabled (not a console handle), so error output stays in the same pipe.
135 +struct CaptureReporter
136 +{
137 + CapturePipe pipe;
138 + wsl::windows::wslc::Reporter reporter;
139 +
140 + explicit CaptureReporter(bool vtEnabled = false) : reporter(pipe.file(), vtEnabled, pipe.file(), vtEnabled)
141 + {
142 + }
143 +
144 + std::wstring captured()
145 + {
146 + return pipe.captured();
147 + }
148 +};
149 +
150 // Helper: capture all lines emitted by a TableOutput into a vector<wstring>.
151 template <size_t N>
152 struct TableOutputCapture