@samitouri / QOSAMI-WSL / commits / 8a92be4b

add --progress to wslc build (#41307)

Add a --progress flag to `wslc build` that controls how build output is rendered, accepting "auto", "tty", "plain", or "quiet". The value is converted to a typed ProgressMode at parse time rather than merely validated, so invalid input is rejected up front with a localized error listing the accepted values. "auto" is the default and resolves to "tty" when progress output is an interactive VT console and "plain" otherwise, matching the previous behavior. "tty" renders the scrolling status window in place, "plain" emits each line sequentially without cursor movement or redraws so the output stays readable when redirected to a file or CI log, and "quiet" suppresses progress entirely while still replaying the failing step's log and the error on failure so diagnosability is preserved. Adds parser, argument, callback, and end-to-end coverage for each mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

ggarzia-MSFT committed Aug 12, 2026 at 11:46 UTC 8a92be4b67b02f5cab70b0766f9c24652252c0f2
16 files changed +415 -19
localization/strings/en-US/Resources.resw
+6 -2
@@ -3224,8 +3224,8 @@ On first run, creates the file with all settings commented out at their defaults
3224 <comment>{Locked="DNS"}Command line arguments should not be translated</comment>
3225 </data>
3226 <data name="WSLCCLI_ProgressArgDescription" xml:space="preserve">
3227 - <value>Progress type (format: none|ansi) (default: ansi)</value>
3228 - <comment>{Locked="none"}{Locked="ansi"}Command line arguments should not be translated</comment>
3227 + <value>Set type of progress output (auto, tty, plain, quiet) (default: auto)</value>
3228 + <comment>{Locked="auto"}{Locked="tty"}{Locked="plain"}{Locked="quiet"}Command line arguments should not be translated</comment>
3229 </data>
3230 <data name="WSLCCLI_PullArgDescription" xml:space="preserve">
3231 <value>Image pull policy (always|missing|never) (default: missing)</value>
@@ -3267,6 +3267,10 @@ On first run, creates the file with all settings commented out at their defaults
3267 <value>Invalid {} value: {} is not a recognized pull policy. Supported pull policies are: {}.</value>
3268 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
3269 </data>
3270 + <data name="WSLCCLI_InvalidProgressTypeError" xml:space="preserve">
3271 + <value>Invalid {} value: {} is not a recognized progress type. Supported progress types are: {}.</value>
3272 + <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
3273 + </data>
3274 <data name="WSLCCLI_InvalidInspectError" xml:space="preserve">
3275 <value>Invalid {} value: {} is not a recognized inspect type. Supported inspect types are: {}.</value>
3276 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
src/windows/wslc/arguments/ArgumentConvertedTypes.h
+1
@@ -38,6 +38,7 @@ namespace wsl::windows::wslc::argument::details {
38 using FormatType = wsl::windows::wslc::models::FormatType;
39 using InspectType = wsl::windows::wslc::models::InspectType;
40 using JsonIndent = int;
41 +using ProgressMode = wsl::windows::wslc::models::ProgressMode;
42 using PullPolicy = wsl::windows::wslc::models::PullPolicy;
43 using WSLCSignal = ::WSLCSignal;
44 using UlimitValue = std::tuple<std::string, int64_t, int64_t>;
src/windows/wslc/arguments/ArgumentDefinitions.h
+1 -1
@@ -109,7 +109,7 @@ _(Output, "output", L"o", Kind::Value,
109 _(Password, "password", L"p", Kind::Value, NoConversion, Localization::WSLCCLI_LoginPasswordArgDescription()) \
110 _(PasswordStdin, "password-stdin", NO_ALIAS, Kind::Flag, NoConversion, Localization::WSLCCLI_LoginPasswordStdinArgDescription()) \
111 _(Path, "path", NO_ALIAS, Kind::Positional, NoConversion, Localization::WSLCCLI_PathArgDescription()) \
112 -/*_(Progress, "progress", NO_ALIAS, Kind::Value, NoConversion, Localization::WSLCCLI_ProgressArgDescription())*/ \
112 +_(Progress, "progress", NO_ALIAS, Kind::Value, ProgressMode, Localization::WSLCCLI_ProgressArgDescription()) \
113 _(Publish, "publish", L"p", Kind::Value, NoConversion, Localization::WSLCCLI_PublishArgDescription()) \
114 _(PublishAll, "publish-all", L"P", Kind::Flag, NoConversion, Localization::WSLCCLI_PublishAllArgDescription()) \
115 _(Pull, "pull", NO_ALIAS, Kind::Value, PullPolicy, Localization::WSLCCLI_PullArgDescription()) \
src/windows/wslc/arguments/ArgumentValidation.cpp
+4
@@ -103,6 +103,10 @@ void Argument::Validate(ArgMap& execArgs) const
103 CacheConverted<ArgType::Pull>(execArgs, m_name, validation::GetPullPolicyFromString);
104 break;
105
106 + case ArgType::Progress:
107 + CacheConverted<ArgType::Progress>(execArgs, m_name, validation::GetProgressModeFromString);
108 + break;
109 +
110 case ArgType::Signal:
111 CacheConverted<ArgType::Signal>(execArgs, m_name, validation::GetWSLCSignalFromString);
112 break;
src/windows/wslc/arguments/SpecParsing.cpp
+31
@@ -682,6 +682,37 @@ models::PullPolicy GetPullPolicyFromString(const std::wstring& input, const std:
682 throw ArgumentException(Localization::WSLCCLI_InvalidPullPolicyError(argName, input, supportedValues));
683 }
684
685 +models::ProgressMode GetProgressModeFromString(const std::wstring& input, const std::wstring& argName)
686 +{
687 + static constexpr std::pair<std::wstring_view, models::ProgressMode> c_progressModes[] = {
688 + {L"auto", models::ProgressMode::Auto},
689 + {L"tty", models::ProgressMode::Tty},
690 + {L"plain", models::ProgressMode::Plain},
691 + {L"quiet", models::ProgressMode::Quiet},
692 + };
693 +
694 + for (const auto& [name, mode] : c_progressModes)
695 + {
696 + if (IsEqual(input, name))
697 + {
698 + return mode;
699 + }
700 + }
701 +
702 + std::wstring supportedValues;
703 + for (const auto& progressMode : c_progressModes)
704 + {
705 + if (!supportedValues.empty())
706 + {
707 + supportedValues += L", ";
708 + }
709 +
710 + supportedValues += progressMode.first;
711 + }
712 +
713 + throw ArgumentException(Localization::WSLCCLI_InvalidProgressTypeError(argName, input, supportedValues));
714 +}
715 +
716 models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName)
717 {
718 if (IsEqual(input, L"image"))
src/windows/wslc/arguments/SpecParsing.h
+3
@@ -87,6 +87,9 @@ int GetInspectJsonIndentFromString(const std::wstring& input, const std::wstring
87 // Parses an image pull policy ("always"/"missing"/"never").
88 models::PullPolicy GetPullPolicyFromString(const std::wstring& input, const std::wstring& argName = {});
89
90 +// Parses a build progress type ("auto"/"tty"/"plain"/"quiet") into a ProgressMode.
91 +models::ProgressMode GetProgressModeFromString(const std::wstring& input, const std::wstring& argName = {});
92 +
93 // Parses an inspect target ("image"/"container"/"network"/"volume") into an InspectType.
94 models::InspectType GetInspectTypeFromString(const std::wstring& input, const std::wstring& argName);
95
src/windows/wslc/commands/ImageBuildCommand.cpp
+1
@@ -36,6 +36,7 @@ std::vector<Argument> ImageBuildCommand::GetArguments() const
36 Argument::Create(ArgType::BuildLabel, false, Limit::Unlimited),
37 Argument::Create(ArgType::NoCache),
38 Argument::Create(ArgType::BuildOutput, false, std::nullopt, Localization::WSLCCLI_BuildOutputArgDescription()),
39 + Argument::Create(ArgType::Progress),
40 Argument::Create(ArgType::Secret, false, Limit::Unlimited),
41 Argument::Create(ArgType::Tag, false, Limit::Unlimited),
42 Argument::Create(ArgType::Verbose),
src/windows/wslc/services/BuildImageCallback.cpp
+41 -9
@@ -52,6 +52,23 @@ bool BuildImageCallback::IsCancelled() const
52 return WaitForSingleObject(m_cancelEvent, 0) == WAIT_OBJECT_0;
53 }
54
55 +const wsl::windows::common::vt::Sequence& BuildImageCallback::Color(const wsl::windows::common::vt::Sequence& sequence) const
56 +{
57 + static const wsl::windows::common::vt::Sequence empty{};
58 + return m_color ? sequence : empty;
59 +}
60 +
61 +void BuildImageCallback::CaptureForReplay(std::string_view text)
62 +{
63 + m_allLines.emplace_back(text);
64 + m_allLinesBytes += m_allLines.back().size();
65 + while (m_allLinesBytes > c_maxAllLinesBytes && !m_allLines.empty())
66 + {
67 + m_allLinesBytes -= m_allLines.front().size();
68 + m_allLines.pop_front();
69 + }
70 +}
71 +
72 void BuildImageCallback::CollapseWindow()
73 {
74 if (m_displayedLines > 0)
@@ -95,9 +112,23 @@ try
112 const bool isLog = (idView == "log");
113 const bool isPullProgress = (!idView.empty() && total > 0 && !isLog);
114
98 - if (m_verbose || !m_isConsole)
115 + // quiet: suppress live progress but retain everything plain would have printed, so the destructor
116 + // can replay the failing step and its logs on build failure. Pull progress is excluded because it
117 + // is rewritten in place rather than appended, and so is the only message with no trailing newline.
118 + if (m_mode == models::ProgressMode::Quiet)
119 + {
120 + if (!isPullProgress)
121 + {
122 + CaptureForReplay(status);
123 + }
124 + return S_OK;
125 + }
126 +
127 + if (m_verbose || !m_renderInPlace)
128 {
100 - // Skip pull progress updates when output is redirected, show only major steps
129 + // Only major steps are reported here. Unlike docker's plain output, which appends
130 + // throttled download lines, pull progress is omitted entirely: without in-place
131 + // updates those lines are mostly noise.
132 if (!isPullProgress)
133 {
134 m_terminal.Info(L"{}", status);
@@ -175,8 +206,9 @@ try
206 wide.resize(bodyLength);
207
208 // Pass the color sequences as arguments (not baked into the string) so Terminal strips
178 - // them when --no-color is set. The trailing newlines are emitted after the reset.
179 - m_terminal.Info(L"{}{}{}{}", Format::Fg::BrightGreen, wide, Format::Default, newlines);
209 + // them when --no-color is set. Color() additionally strips them outside Tty mode. The
210 + // trailing newlines are emitted after the reset.
211 + m_terminal.Info(L"{}{}{}{}", Color(Format::Fg::BrightGreen), wide, Color(Format::Default), newlines);
212 return S_OK;
213 }
214 CATCH_RETURN();
@@ -196,10 +228,10 @@ void BuildImageCallback::Redraw()
228 const int displayCount = completedCount + reservedLines;
229
230 // Build the frame body in one buffer to minimize console writes. The cursor moves,
199 - // erases, and text lines it holds are non-color VT that only runs when a VT console is
200 - // attached. The cursor hide/show wrapper and the dim intensity attribute are passed as
201 - // Sequence arguments to Terminal (below) so it strips the color ones (Dim/Normal) when
202 - // --no-color is set, while leaving the non-color cursor moves intact.
231 + // erases, and text lines it holds are non-color VT. This only runs in Tty mode, where a
232 + // VT console is attached. The cursor hide/show wrapper and the dim intensity attribute are
233 + // passed as Sequence arguments to Terminal (below) so it strips the color ones (Dim/Normal)
234 + // when --no-color is set, while leaving the non-color cursor moves intact.
235 //
236 // m_frameBuffer is a member so its backing allocation is reused across frames -
237 // it grows to the high-water mark and is never freed between redraws.
@@ -251,7 +283,7 @@ void BuildImageCallback::Redraw()
283 // Emit the frame as a single atomic write. Cursor Hide/Show are non-color and always
284 // rendered here (VT is on); Format::Dim/Normal are color sequences that Terminal strips
285 // under --no-color. The buffered body carries the cursor moves, erases, and text lines.
254 - m_terminal.Info(L"{}{}{}{}{}", Cursor::Hide, Format::Dim, std::wstring_view{m_frameBuffer}, Format::Normal, Cursor::Show);
286 + m_terminal.Info(L"{}{}{}{}{}", Cursor::Hide, Color(Format::Dim), std::wstring_view{m_frameBuffer}, Color(Format::Normal), Cursor::Show);
287 m_displayedLines = displayCount;
288 }
289
src/windows/wslc/services/BuildImageCallback.h
+15 -3
@@ -12,6 +12,7 @@ Abstract:
12
13 --*/
14 #pragma once
15 +#include "ContainerModel.h"
16 #include "Terminal.h"
17 #include "SessionService.h"
18 #include "VTSupport.h"
@@ -24,8 +25,9 @@ class DECLSPEC_UUID("3EDD5DBF-CA6C-4CF7-923A-AD94B6A732E5") BuildImageCallback
25 {
26 public:
27 // The cancel event handle must remain valid for the lifetime of this callback.
27 - BuildImageCallback(Terminal& terminal, HANDLE cancelEvent, bool verbose) :
28 - m_terminal(terminal), m_verbose(verbose), m_cancelEvent(cancelEvent)
28 + // Mode selects the rendering style (Auto is expected to already be resolved to Tty/Plain by the caller).
29 + BuildImageCallback(Terminal& terminal, HANDLE cancelEvent, bool verbose, models::ProgressMode mode = models::ProgressMode::Tty) :
30 + m_terminal(terminal), m_verbose(verbose), m_cancelEvent(cancelEvent), m_mode(mode), m_color(mode == models::ProgressMode::Tty)
31 {
32 }
33 ~BuildImageCallback();
@@ -40,11 +42,21 @@ private:
42 void Redraw();
43 void RedrawIfNeeded();
44 bool IsCancelled() const;
45 + // Appends a log chunk to the error-replay buffer, enforcing the retained-bytes cap.
46 + void CaptureForReplay(std::string_view text);
47 + // Returns the sequence when color is enabled for this callback, else an empty (no-op) sequence so
48 + // the Terminal emits nothing for it. Used to strip color from the sequences emitted in Tty mode.
49 + const wsl::windows::common::vt::Sequence& Color(const wsl::windows::common::vt::Sequence& sequence) const;
50
51 Terminal& m_terminal;
52 const bool m_verbose;
53 const HANDLE m_cancelEvent;
47 - bool m_isConsole = m_terminal.IsVTEnabled(Terminal::Level::Info);
54 + const models::ProgressMode m_mode;
55 + const bool m_color;
56 + // In-place rendering (cursor moves, erases and redraws) is only used for Tty mode on a VT
57 + // console. Plain mode appends one line at a time so its output carries no cursor control and
58 + // is identical whether it goes to a console or a redirected stream.
59 + bool m_renderInPlace = m_mode == models::ProgressMode::Tty && m_terminal.IsVTEnabled(Terminal::Level::Info);
60 std::deque<std::string> m_lines;
61 // Each entry already contains the trailing newline so the bytes match what's replayed.
62 // TODO: Track logs per step so the destructor can replay only the failing step's
src/windows/wslc/services/ContainerModel.h
+10
@@ -34,6 +34,16 @@ enum class PullPolicy
34 Never,
35 };
36
37 +// Progress output style for `wslc build`. Auto resolves to Tty when progress output is an
38 +// interactive VT console and Plain otherwise.
39 +enum class ProgressMode
40 +{
41 + Auto,
42 + Tty,
43 + Plain,
44 + Quiet,
45 +};
46 +
47 struct ContainerOptions
48 {
49 std::vector<std::string> Arguments;
src/windows/wslc/tasks/ImageTasks.cpp
+9 -1
@@ -131,8 +131,16 @@ void BuildImage(CLIExecutionContext& context)
131 WI_SetFlagIf(flags, WSLCBuildImageFlagsNoCache, context.Args.GetValue<ArgType::NoCache>());
132 WI_SetFlagIf(flags, WSLCBuildImageFlagsPull, context.Args.GetValue<ArgType::BuildPull>());
133
134 + auto progressMode = context.Args.GetValue<ArgType::Progress>(ProgressMode::Auto);
135 +
136 + // Resolve Auto based on whether progress output (stderr) is an interactive VT console.
137 + if (progressMode == ProgressMode::Auto)
138 + {
139 + progressMode = context.Terminal.IsVTEnabled(Terminal::Level::Info) ? ProgressMode::Tty : ProgressMode::Plain;
140 + }
141 +
142 auto cancelEvent = context.CreateCancelEvent();
135 - BuildImageCallback callback(context.Terminal, cancelEvent, context.Args.GetValue<ArgType::Verbose>());
143 + BuildImageCallback callback(context.Terminal, cancelEvent, context.Args.GetValue<ArgType::Verbose>(), progressMode);
144 services::ImageService::Build(
145 session, contextPath, tags, buildArgs, labels, secrets, dockerfilePath, target, output, iidFilePath, flags, &callback, cancelEvent);
146 }
src/windows/wslcsession/WSLCSession.cpp
+3 -2
@@ -1022,6 +1022,8 @@ try
1022
1023 auto mountPath = mountInVm(Options->ContextPath, TRUE);
1024
1025 + // Progress is requested as JSON so it can be parsed into the formatted progress messages sent to the
1026 + // client. The raw JSON is a docker implementation detail and is never forwarded.
1027 std::vector<std::string> buildArgs{"/usr/bin/docker", "buildx", "build", "--builder", "default", "--progress=rawjson"};
1028 if (WI_IsFlagSet(Options->Flags, WSLCBuildImageFlagsNoCache))
1029 {
@@ -1413,8 +1415,7 @@ try
1415 }
1416 };
1417
1416 - // With --progress=rawjson, docker writes progress to stderr and the final image ID to stdout on success (empty on
1417 - // failure).
1418 + // Docker writes progress to stderr and the final image ID to stdout on success (empty on failure).
1419 //
1420 // For dest=- the exporter tarball is written to stdout, so it is relayed to the client handle as the
1421 // build runs. RelayHandle is an overlapped handle, so a slow client only marks the relay pending and
test/windows/wslc/ParserTestCases.h
+41 -1
@@ -24,6 +24,7 @@ enum class ArgumentSet
24 {
25 Run,
26 List,
27 + Build,
28 // RootCommand globals; parsed in optionsOnly mode (stops at first positional).
29 Globals,
30 };
@@ -73,6 +74,25 @@ inline std::vector<wsl::windows::wslc::Argument> GetArgumentsForSet(ArgumentSet
74 Argument::Create(ArgType::Verbose),
75 };
76
77 + case ArgumentSet::Build:
78 + // Mirrors ImageBuildCommand::GetArguments() so the parser tests exercise the
79 + // real `wslc build` option set (notably the --progress value option).
80 + return {
81 + Argument::Create(ArgType::Path, true), // Required positional (build context path)
82 + Argument::Create(ArgType::BuildArg, false, Limit::Unlimited),
83 + Argument::Create(ArgType::BuildPull),
84 + Argument::Create(ArgType::BuildTarget),
85 + Argument::Create(ArgType::File),
86 + Argument::Create(ArgType::Label, false, Limit::Unlimited),
87 + Argument::Create(ArgType::NoCache),
88 + Argument::Create(ArgType::Output),
89 + Argument::Create(ArgType::Progress),
90 + Argument::Create(ArgType::Secret, false, Limit::Unlimited),
91 + Argument::Create(ArgType::Tag, false, Limit::Unlimited),
92 + Argument::Create(ArgType::Verbose),
93 + Argument::Create(ArgType::Help),
94 + };
95 +
96 case ArgumentSet::Globals:
97 // Synthetic stand-in for what Main.cpp passes as cliGlobals to the
98 // first (optionsOnly) parse pass. Decoupled from RootCommand so the
@@ -222,5 +242,25 @@ WSLC_PARSER_TEST_CASE(Globals, true, LR"(wslc --quiet --session foo image1)") \
242 WSLC_PARSER_TEST_CASE(Globals, true, LR"(wslc --session foo -q image1)") \
243 /* Docker-style idempotency: duplicate global flags collapse to a single entry. */ \
244 WSLC_PARSER_TEST_CASE(Globals, true, LR"(wslc --quiet --quiet)") \
225 -WSLC_PARSER_TEST_CASE(Globals, true, LR"(wslc -q -q system list)")
245 +WSLC_PARSER_TEST_CASE(Globals, true, LR"(wslc -q -q system list)") \
246 +\
247 +/* `wslc build` --progress option (Build set mirrors ImageBuildCommand). The build \
248 + * context path is the required positional; --progress takes one of auto/tty/plain/ \
249 + * quiet and is validated by Argument::Validate via GetProgressModeFromString. */ \
250 +/* Valid modes, separated and adjoined value forms, and case-sensitivity. */ \
251 +WSLC_PARSER_TEST_CASE(Build, true, LR"(wslc . --progress=auto)") \
252 +WSLC_PARSER_TEST_CASE(Build, true, LR"(wslc . --progress auto)") \
253 +WSLC_PARSER_TEST_CASE(Build, true, LR"(wslc --progress=tty .)") \
254 +WSLC_PARSER_TEST_CASE(Build, true, LR"(wslc . --progress=plain)") \
255 +WSLC_PARSER_TEST_CASE(Build, true, LR"(wslc . --progress quiet)") \
256 +/* Values are case-sensitive (lowercase only), matching Docker and --format. */ \
257 +WSLC_PARSER_TEST_CASE(Build, false, LR"(wslc . --progress=TTY)") \
258 +/* A build with no --progress at all is valid (the option is optional). */ \
259 +WSLC_PARSER_TEST_CASE(Build, true, LR"(wslc .)") \
260 +/* Invalid / unrecognized modes, empty value, and missing value at end of input. */ \
261 +WSLC_PARSER_TEST_CASE(Build, false, LR"(wslc . --progress=fancy)") \
262 +WSLC_PARSER_TEST_CASE(Build, false, LR"(wslc . --progress bogus)") \
263 +WSLC_PARSER_TEST_CASE(Build, false, LR"(wslc . --progress=json)") \
264 +WSLC_PARSER_TEST_CASE(Build, false, LR"(wslc . --progress=)") \
265 +WSLC_PARSER_TEST_CASE(Build, false, LR"(wslc . --progress)")
266 // clang-format on
test/windows/wslc/WSLCCLIArgumentUnitTests.cpp
+8
@@ -162,6 +162,14 @@ class WSLCCLIArgumentUnitTests
162 VERIFY_ARE_EQUAL(pullPolicy, PullPolicy::Never);
163 VERIFY_THROWS(validation::GetPullPolicyFromString(L"invalid"), ArgumentException);
164
165 + // Verify build progress mode
166 + VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"auto"), ProgressMode::Auto);
167 + VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"tty"), ProgressMode::Tty);
168 + VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"plain"), ProgressMode::Plain);
169 + VERIFY_ARE_EQUAL(validation::GetProgressModeFromString(L"quiet"), ProgressMode::Quiet);
170 + VERIFY_THROWS(validation::GetProgressModeFromString(L"TTY"), ArgumentException); // Case-sensitive: only lowercase accepted
171 + VERIFY_THROWS(validation::GetProgressModeFromString(L"fancy"), ArgumentException);
172 +
173 // Verify GPU device argument
174 VERIFY_NO_THROW(validation::ValidateGpus({L"all"}, L"gpusArg"));
175 VERIFY_THROWS(validation::ValidateGpus({L"none"}, L"gpusArg"), ArgumentException);
test/windows/wslc/WSLCCLIBuildImageCallbackUnitTests.cpp new
+164
@@ -0,0 +1,164 @@
1 +/*++
2 +
3 +Copyright (c) Microsoft. All rights reserved.
4 +
5 +Module Name:
6 +
7 + WSLCCLIBuildImageCallbackUnitTests.cpp
8 +
9 +Abstract:
10 +
11 + Unit tests for BuildImageCallback progress rendering.
12 +
13 +--*/
14 +
15 +#include "precomp.h"
16 +#include "windows/Common.h"
17 +#include "WSLCCLITestHelpers.h"
18 +
19 +#include "BuildImageCallback.h"
20 +#include "ContainerModel.h"
21 +#include "Terminal.h"
22 +
23 +using namespace wsl::windows::wslc;
24 +using namespace wsl::windows::wslc::models;
25 +using namespace wsl::windows::wslc::services;
26 +using namespace WSLCTestHelpers;
27 +using namespace WEX::Logging;
28 +using namespace WEX::Common;
29 +using namespace WEX::TestExecution;
30 +
31 +namespace WSLCCLIBuildImageCallbackUnitTests {
32 +
33 +namespace {
34 +
35 + // Drives a callback through a representative build: a step header, multi-line log output,
36 + // a \r-based in-place progress update, and a pull progress entry. Returns everything the
37 + // terminal emitted, captured after the callback is destroyed so its final frame is included.
38 + std::wstring RunBuild(ProgressMode mode, bool vtEnabled, bool verbose = false)
39 + {
40 + CaptureTerminal capture(vtEnabled);
41 + wil::unique_event cancelEvent;
42 + cancelEvent.create(wil::EventOptions::ManualReset);
43 +
44 + {
45 + BuildImageCallback callback(capture.terminal, cancelEvent.get(), verbose, mode);
46 +
47 + VERIFY_SUCCEEDED(callback.OnProgress("#1 [1/2] FROM debian:latest\n", "", 0, 0));
48 + VERIFY_SUCCEEDED(callback.OnProgress("first log line\nsecond log line\n", "log", 0, 0));
49 + VERIFY_SUCCEEDED(callback.OnProgress("downloading 10%\rdownloading 80%\r", "log", 0, 0));
50 + VERIFY_SUCCEEDED(callback.OnProgress("sha256:abc downloading", "sha256:abc", 50, 100));
51 + VERIFY_SUCCEEDED(callback.OnProgress("#2 [2/2] RUN echo hi\n", "", 0, 0));
52 + }
53 +
54 + return capture.captured();
55 + }
56 +
57 + // Drives the same build as RunBuild, but destroys the callback while an exception is in flight,
58 + // which is the only condition under which the destructor replays captured output.
59 + std::wstring RunFailingBuild(ProgressMode mode, bool vtEnabled)
60 + {
61 + CaptureTerminal capture(vtEnabled);
62 + wil::unique_event cancelEvent;
63 + cancelEvent.create(wil::EventOptions::ManualReset);
64 +
65 + try
66 + {
67 + BuildImageCallback callback(capture.terminal, cancelEvent.get(), false, mode);
68 +
69 + VERIFY_SUCCEEDED(callback.OnProgress("#1 [1/2] FROM debian:latest\n", "", 0, 0));
70 + VERIFY_SUCCEEDED(callback.OnProgress("sha256:abc downloading", "sha256:abc", 50, 100));
71 + VERIFY_SUCCEEDED(callback.OnProgress("#2 [2/2] RUN exit 7\n", "", 0, 0));
72 + VERIFY_SUCCEEDED(callback.OnProgress(" | about-to-fail\n", "log", 0, 0));
73 + VERIFY_SUCCEEDED(callback.OnProgress("process \"/bin/sh -c exit 7\" did not complete successfully: exit code: 7\n", "", 0, 0));
74 +
75 + THROW_HR(E_FAIL);
76 + }
77 + catch (...)
78 + {
79 + }
80 +
81 + return capture.captured();
82 + }
83 +
84 +} // namespace
85 +
86 +class WSLCCLIBuildImageCallbackUnitTests
87 +{
88 + WSLC_TEST_CLASS(WSLCCLIBuildImageCallbackUnitTests)
89 +
90 + TEST_CLASS_SETUP(TestClassSetup)
91 + {
92 + return true;
93 + }
94 +
95 + TEST_CLASS_CLEANUP(TestClassCleanup)
96 + {
97 + return true;
98 + }
99 +
100 + // plain must never redraw. Even when a VT console is attached (the case an E2E test with
101 + // redirected output cannot reach), the output must be append-only: no cursor movement, no
102 + // erases, and no escape sequences of any kind.
103 + TEST_METHOD(BuildImageCallback_PlainOnVtConsole_EmitsNoEscapeSequences)
104 + {
105 + const auto output = RunBuild(ProgressMode::Plain, true);
106 +
107 + VERIFY_IS_TRUE(output.find(L'\x1b') == std::wstring::npos, L"plain mode must not emit any VT escape sequence");
108 + VERIFY_IS_TRUE(output.find(L"#1 [1/2] FROM debian:latest") != std::wstring::npos, L"build steps must still be reported");
109 + VERIFY_IS_TRUE(output.find(L"#2 [2/2] RUN echo hi") != std::wstring::npos, L"build steps must still be reported");
110 + }
111 +
112 + // Control: the same input in tty mode does emit cursor control. Without this, the test above
113 + // could pass simply because the rendering path was never exercised.
114 + TEST_METHOD(BuildImageCallback_TtyOnVtConsole_EmitsEscapeSequences)
115 + {
116 + const auto output = RunBuild(ProgressMode::Tty, true);
117 +
118 + VERIFY_IS_TRUE(output.find(L'\x1b') != std::wstring::npos, L"tty mode is expected to render in place using VT sequences");
119 + }
120 +
121 + // plain must produce the same escape-free output regardless of whether a console is attached,
122 + // so redirecting a plain build to a file yields exactly what was shown on screen.
123 + TEST_METHOD(BuildImageCallback_PlainMatchesRedirectedOutput)
124 + {
125 + const auto onConsole = RunBuild(ProgressMode::Plain, true);
126 + const auto redirected = RunBuild(ProgressMode::Plain, false);
127 +
128 + VERIFY_ARE_EQUAL(redirected, onConsole);
129 + }
130 +
131 + // quiet suppresses progress entirely on success, and must not emit cursor control either.
132 + // This also covers the success side of replay: nothing captured may reach the terminal.
133 + TEST_METHOD(BuildImageCallback_QuietOnVtConsole_EmitsNothing)
134 + {
135 + const auto output = RunBuild(ProgressMode::Quiet, true);
136 +
137 + VERIFY_ARE_EQUAL(std::wstring{L""}, output);
138 + }
139 +
140 + // quiet prints nothing while the build runs, but a failure must still explain what went wrong.
141 + // The step that failed and the error itself are reported with an empty id rather than "log", so
142 + // they have to be captured too or the replay only shows unattributed log output.
143 + TEST_METHOD(BuildImageCallback_QuietReplaysFailingStepAndError)
144 + {
145 + const auto output = RunFailingBuild(ProgressMode::Quiet, true);
146 +
147 + VERIFY_IS_TRUE(output.find(L"#2 [2/2] RUN exit 7") != std::wstring::npos, L"quiet must replay the step that failed");
148 + VERIFY_IS_TRUE(
149 + output.find(L"did not complete successfully: exit code: 7") != std::wstring::npos,
150 + L"quiet must replay the build error");
151 + VERIFY_IS_TRUE(output.find(L"about-to-fail") != std::wstring::npos, L"quiet must replay log output");
152 + }
153 +
154 + // Pull progress is rewritten in place and is the one message sent without a trailing newline,
155 + // so replaying it would emit a partial line into an otherwise line-oriented transcript.
156 + TEST_METHOD(BuildImageCallback_QuietReplayOmitsPullProgress)
157 + {
158 + const auto output = RunFailingBuild(ProgressMode::Quiet, true);
159 +
160 + VERIFY_IS_TRUE(output.find(L"sha256:abc downloading") == std::wstring::npos, L"quiet must not replay pull progress");
161 + }
162 +};
163 +
164 +} // namespace WSLCCLIBuildImageCallbackUnitTests
test/windows/wslc/e2e/WSLCE2EImageBuildTests.cpp
+77
@@ -1430,6 +1430,81 @@ class WSLCE2EImageBuildTests
1430 VERIFY_IS_FALSE(contents.starts_with(L"sha256:"), L"a failed iidfile write must not leave an image ID behind");
1431 }
1432
1433 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_ProgressInvalid_Fails)
1434 + {
1435 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-progress-bad";
1436 + auto cleanup = SetupTestDirectory(testRoot);
1437 +
1438 + auto contextDir = testRoot / L"context";
1439 + std::error_code ec;
1440 + std::filesystem::create_directories(contextDir, ec);
1441 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1442 +
1443 + auto dockerfilePath = testRoot / L"Dockerfile";
1444 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\n");
1445 +
1446 + // Invalid --progress values are rejected client-side before any build runs.
1447 + auto buildResult =
1448 + RunWslc(std::format(L"build \"{}\" -f \"{}\" --progress=bogus", contextDir.wstring(), dockerfilePath.wstring()));
1449 + VERIFY_ARE_EQUAL(1u, buildResult.ExitCode.value_or(0u));
1450 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1451 + VERIFY_IS_TRUE(buildResult.Stderr->find(L"is not a recognized progress type") != std::wstring::npos);
1452 + }
1453 +
1454 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_ProgressPlain_NoEscapeSequences_Success)
1455 + {
1456 + auto imageCleanup = DeleteImageOnExit(BuiltImageProgressPlain);
1457 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-progress-plain";
1458 + auto cleanup = SetupTestDirectory(testRoot);
1459 +
1460 + auto contextDir = testRoot / L"context";
1461 + std::error_code ec;
1462 + std::filesystem::create_directories(contextDir, ec);
1463 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1464 +
1465 + auto dockerfilePath = testRoot / L"Dockerfile";
1466 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"plain-ok\"]\n");
1467 +
1468 + // plain emits progress text but never color/cursor VT escape sequences (ESC, 0x1b).
1469 + auto buildResult = RunWslc(std::format(
1470 + L"build \"{}\" -f \"{}\" -t {} --progress=plain",
1471 + contextDir.wstring(),
1472 + dockerfilePath.wstring(),
1473 + BuiltImageProgressPlain.NameAndTag()));
1474 + buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1475 +
1476 + VERIFY_IS_TRUE(buildResult.Stderr.has_value());
1477 + VERIFY_IS_TRUE(buildResult.Stderr->find(L'\x1b') == std::wstring::npos, L"plain mode must not emit VT escape sequences");
1478 + }
1479 +
1480 + WSLC_TEST_METHOD(WSLCE2E_Image_Build_ProgressQuiet_NoProgressOutput_Success)
1481 + {
1482 + auto imageCleanup = DeleteImageOnExit(BuiltImageProgressQuiet);
1483 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-build-progress-quiet";
1484 + auto cleanup = SetupTestDirectory(testRoot);
1485 +
1486 + auto contextDir = testRoot / L"context";
1487 + std::error_code ec;
1488 + std::filesystem::create_directories(contextDir, ec);
1489 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1490 +
1491 + auto dockerfilePath = testRoot / L"Dockerfile";
1492 + WriteTestFileContent(dockerfilePath, "FROM debian:latest\nCMD [\"echo\", \"quiet-ok\"]\n");
1493 +
1494 + // quiet suppresses all progress output on a successful build.
1495 + auto buildResult = RunWslc(std::format(
1496 + L"build \"{}\" -f \"{}\" -t {} --progress=quiet",
1497 + contextDir.wstring(),
1498 + dockerfilePath.wstring(),
1499 + BuiltImageProgressQuiet.NameAndTag()));
1500 + buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1501 +
1502 + if (buildResult.Stderr.has_value())
1503 + {
1504 + VERIFY_IS_TRUE(buildResult.Stderr->empty(), L"quiet mode must not emit build progress on success");
1505 + }
1506 + }
1507 +
1508 private:
1509 const TestImage BuiltImage{L"wslc-e2e-build-empty-context", L"latest", L""};
1510 const TestImage BuiltImageTag1{L"wslc-e2e-build-args-tags", L"v1", L""};
@@ -1466,6 +1541,8 @@ private:
1541 const TestImage BuiltImageOutputCacheOnly{L"wslc-e2e-build-output-cacheonly", L"latest", L""};
1542 const TestImage BuiltImageIidFile{L"wslc-e2e-build-iidfile", L"latest", L""};
1543 const TestImage BuiltImageIidFileNotWritable{L"wslc-e2e-build-iidfile-readonly", L"latest", L""};
1544 + const TestImage BuiltImageProgressPlain{L"wslc-e2e-build-progress-plain", L"latest", L""};
1545 + const TestImage BuiltImageProgressQuiet{L"wslc-e2e-build-progress-quiet", L"latest", L""};
1546 const TestImage BuiltImageIidFileRelative{L"wslc-e2e-build-iidfile-relative", L"latest", L""};
1547
1548 // Runs `tar.exe -tf <path>` and returns the member listing so tests can assert an exporter produced a