Align container list format with Docker specifications (#41375)
wslc: match column order, status text, and json shape for container list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ggarzia-MSFT committed
Aug 31, 2026 at 15:33 UTC
46be5bb59bcf2a012a5cd3d3114cf509d26af42e
21 files changed
+1126
-91
localization/strings/en-US/Resources.resw
+26
@@ -3689,6 +3689,12 @@ On first run, creates the file with all settings commented out at their defaults
3689
<data name="WSLCCLI_TableHeaderName" xml:space="preserve">
3690
<value>NAME</value>
3691
</data>
3692
+ <data name="WSLCCLI_TableHeaderNames" xml:space="preserve">
3693
+ <value>NAMES</value>
3694
+ </data>
3695
+ <data name="WSLCCLI_TableHeaderCommand" xml:space="preserve">
3696
+ <value>COMMAND</value>
3697
+ </data>
3698
<data name="WSLCCLI_TableHeaderImage" xml:space="preserve">
3699
<value>IMAGE</value>
3700
</data>
@@ -3719,6 +3725,26 @@ On first run, creates the file with all settings commented out at their defaults
3725
<data name="WSLCCLI_TableHeaderPids" xml:space="preserve">
3726
<value>PIDS</value>
3727
</data>
3728
+ <data name="WSLCCLI_ContainerStateCreated" xml:space="preserve">
3729
+ <value>created</value>
3730
+ <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3731
+ </data>
3732
+ <data name="WSLCCLI_ContainerStateRunning" xml:space="preserve">
3733
+ <value>running</value>
3734
+ <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3735
+ </data>
3736
+ <data name="WSLCCLI_ContainerStateStopped" xml:space="preserve">
3737
+ <value>stopped</value>
3738
+ <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3739
+ </data>
3740
+ <data name="WSLCCLI_ContainerStateExited" xml:space="preserve">
3741
+ <value>exited</value>
3742
+ <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3743
+ </data>
3744
+ <data name="WSLCCLI_ContainerStateInvalid" xml:space="preserve">
3745
+ <value>invalid</value>
3746
+ <comment>Container state shown in the STATUS column of `wslc container list`.</comment>
3747
+ </data>
3748
<data name="WSLCCLI_RelativeTimeLessThanASecond" xml:space="preserve">
3749
<value>Less than a second ago</value>
3750
<comment>Describes how long ago something happened, shown in the CREATED column of 'wslc container list' and 'wslc image list'.</comment>
src/windows/common/CMakeLists.txt
+1
@@ -130,6 +130,7 @@ set(HEADERS
130
VTSupport.h
131
WindowsUpdateIntegration.h
132
WSLCContainerLauncher.h
133
+ WSLCContainerEntry.h
134
ConsommeNetworking.h
135
WSLCProcessLauncher.h
136
WslClient.h
src/windows/common/WSLCContainerEntry.h
new
+29
@@ -0,0 +1,29 @@
1
+// Copyright (C) Microsoft Corporation. All rights reserved.
2
+
3
+#pragma once
4
+
5
+#include <wil/resource.h>
6
+#include "wslc.h"
7
+
8
+namespace wsl::windows::common::wslc {
9
+
10
+// IWSLCSession::ListContainers allocates a string for every unbounded field on an entry, so each
11
+// element owns memory that releasing the array alone would miss. Safe to call on a partially
12
+// populated entry because unset fields are null.
13
+inline void FreeContainerEntryStrings(_Inout_ WSLCContainerEntry* Entry)
14
+{
15
+ CoTaskMemFree(Entry->Command);
16
+ CoTaskMemFree(Entry->Status);
17
+ CoTaskMemFree(Entry->Labels);
18
+ CoTaskMemFree(Entry->Networks);
19
+ CoTaskMemFree(Entry->Mounts);
20
+}
21
+
22
+// Owns both the entry array and the per-entry strings. Callers of ListContainers should use this
23
+// rather than a plain cotaskmem array so the strings cannot be leaked. The array still holds raw
24
+// entries, so it can be passed to the interface as-is.
25
+using unique_container_entry = wil::unique_struct<WSLCContainerEntry, decltype(&FreeContainerEntryStrings), FreeContainerEntryStrings>;
26
+
27
+using unique_container_entry_array = wil::unique_cotaskmem_array_ptr<unique_container_entry>;
28
+
29
+} // namespace wsl::windows::common::wslc
src/windows/common/string.cpp
+51
@@ -406,3 +406,54 @@ std::string wsl::windows::common::string::TruncateId(_In_ std::string_view id, b
406
{
407
return TruncateIdImpl(id, shortenLength);
408
}
409
+
410
+// Returns the number of terminal columns a code point occupies. This mirrors docker's charWidth, which treats
411
+// East Asian wide and fullwidth code points as two columns and everything else as one.
412
+static size_t CharacterWidth(UChar32 CodePoint)
413
+{
414
+ const auto width = u_getIntPropertyValue(CodePoint, UCHAR_EAST_ASIAN_WIDTH);
415
+ return (width == U_EA_WIDE || width == U_EA_FULLWIDTH) ? 2 : 1;
416
+}
417
+
418
+std::wstring wsl::windows::common::string::Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth)
419
+{
420
+ if (MaxDisplayWidth == 0 || Value.empty())
421
+ {
422
+ return {};
423
+ }
424
+
425
+ const auto length = gsl::narrow_cast<int32_t>(Value.size());
426
+ if (MaxDisplayWidth == 1)
427
+ {
428
+ // There is no room for both content and an ellipsis, so the leading code point is kept as-is even
429
+ // if it is wider than the limit.
430
+ int32_t index = 0;
431
+ UChar32 codePoint{};
432
+ U16_NEXT(Value.data(), index, length, codePoint);
433
+ return std::wstring{Value.substr(0, index)};
434
+ }
435
+
436
+ // The ellipsis occupies one column, so the retained content has one column less to work with.
437
+ const auto budget = MaxDisplayWidth - 1;
438
+ size_t totalWidth = 0;
439
+ size_t cutoff = 0;
440
+ for (int32_t index = 0; index < length;)
441
+ {
442
+ UChar32 codePoint{};
443
+ U16_NEXT(Value.data(), index, length, codePoint);
444
+ totalWidth += CharacterWidth(codePoint);
445
+ if (totalWidth <= budget)
446
+ {
447
+ cutoff = index;
448
+ }
449
+ }
450
+
451
+ // A cutoff of zero means the first code point alone leaves no room for the ellipsis, in which case docker
452
+ // returns the value untouched.
453
+ if (totalWidth <= MaxDisplayWidth || cutoff == 0)
454
+ {
455
+ return std::wstring{Value};
456
+ }
457
+
458
+ return std::wstring{Value.substr(0, cutoff)} + L'\u2026';
459
+}
src/windows/common/string.hpp
+6
@@ -68,6 +68,12 @@ std::string WideToMultiByte(_In_ std::wstring_view Source);
68
std::wstring TruncateId(_In_ std::wstring_view id, bool shortenLength = true);
69
std::string TruncateId(_In_ std::string_view id, bool shortenLength = true);
70
71
+// Shortens a value so it occupies at most MaxDisplayWidth terminal columns, appending an ellipsis when
72
+// characters are dropped. East Asian wide and fullwidth code points occupy two columns, so fewer of them
73
+// fit than narrow ones, and a code point is never split. This matches docker's formatter.Ellipsis
74
+// (cli/command/formatter/displayutils.go), including its handling of widths of one and below.
75
+std::wstring Ellipsis(_In_ std::wstring_view Value, _In_ size_t MaxDisplayWidth);
76
+
77
// Template implementation for TruncateId to avoid code duplication.
78
// Algorithm inspired from Moby for consistency in presentation of shortened IDs.
79
// Always strips the algorithm prefix (e.g., "sha256:") if present, and optionally shortens to 12 characters.
src/windows/common/timestamp.cpp
+115
-13
@@ -311,10 +311,36 @@ std::string wsl::windows::common::timestamp::Rfc3339ToUtcDisplayTime(std::string
311
return std::format("{:%F %T}{} +0000 UTC", parsed, fraction);
312
}
313
314
-std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elapsedSeconds)
314
+namespace {
315
+
316
+enum class ElapsedUnit
317
+{
318
+ LessThanASecond,
319
+ OneSecond,
320
+ Seconds,
321
+ AboutAMinute,
322
+ Minutes,
323
+ AboutAnHour,
324
+ Hours,
325
+ Days,
326
+ Weeks,
327
+ Months,
328
+ Years,
329
+};
330
+
331
+struct ElapsedDuration
332
+{
333
+ ElapsedUnit Unit;
334
+ LONGLONG Count;
335
+};
336
+
337
+} // namespace
338
+
339
+// Buckets an elapsed duration using the thresholds docker applies in go-units HumanDuration. The
340
+// localized and invariant renderings share this so the two can only differ in wording.
341
+static ElapsedDuration ClassifyElapsedSeconds(LONGLONG elapsedSeconds)
342
{
343
using namespace std::chrono_literals;
317
- using wsl::shared::Localization;
344
345
constexpr LONGLONG SecondsPerMinute = std::chrono::duration_cast<std::chrono::seconds>(1min).count();
346
constexpr LONGLONG SecondsPerHour = std::chrono::duration_cast<std::chrono::seconds>(1h).count();
@@ -325,51 +351,117 @@ std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elap
351
352
if (elapsed < 1)
353
{
328
- return Localization::WSLCCLI_RelativeTimeLessThanASecond();
354
+ return {ElapsedUnit::LessThanASecond, 0};
355
}
356
else if (elapsed == 1)
357
{
332
- return Localization::WSLCCLI_RelativeTimeOneSecond();
358
+ return {ElapsedUnit::OneSecond, 1};
359
}
360
else if (elapsed < SecondsPerMinute)
361
{
336
- return Localization::WSLCCLI_RelativeTimeSeconds(elapsed);
362
+ return {ElapsedUnit::Seconds, elapsed};
363
}
364
365
const auto minutes = elapsed / SecondsPerMinute;
366
if (minutes == 1)
367
{
342
- return Localization::WSLCCLI_RelativeTimeAboutAMinute();
368
+ return {ElapsedUnit::AboutAMinute, 1};
369
}
370
else if (minutes < MinutesPerHour)
371
{
346
- return Localization::WSLCCLI_RelativeTimeMinutes(minutes);
372
+ return {ElapsedUnit::Minutes, minutes};
373
}
374
375
// Rounded to the nearest hour rather than truncated.
376
const auto hours = (elapsed + (SecondsPerHour / 2)) / SecondsPerHour;
377
if (hours == 1)
378
{
353
- return Localization::WSLCCLI_RelativeTimeAboutAnHour();
379
+ return {ElapsedUnit::AboutAnHour, 1};
380
}
381
else if (hours < HoursPerDay * 2)
382
{
357
- return Localization::WSLCCLI_RelativeTimeHours(hours);
383
+ return {ElapsedUnit::Hours, hours};
384
}
385
else if (hours < HoursPerDay * 7 * 2)
386
{
361
- return Localization::WSLCCLI_RelativeTimeDays(hours / HoursPerDay);
387
+ return {ElapsedUnit::Days, hours / HoursPerDay};
388
}
389
else if (hours < HoursPerDay * 30 * 2)
390
{
365
- return Localization::WSLCCLI_RelativeTimeWeeks(hours / HoursPerDay / 7);
391
+ return {ElapsedUnit::Weeks, hours / HoursPerDay / 7};
392
}
393
else if (hours < HoursPerDay * 365 * 2)
394
{
369
- return Localization::WSLCCLI_RelativeTimeMonths(hours / HoursPerDay / 30);
395
+ return {ElapsedUnit::Months, hours / HoursPerDay / 30};
396
}
397
372
- return Localization::WSLCCLI_RelativeTimeYears(elapsed / SecondsPerHour / HoursPerDay / 365);
398
+ return {ElapsedUnit::Years, elapsed / SecondsPerHour / HoursPerDay / 365};
399
+}
400
+
401
+std::wstring wsl::windows::common::timestamp::FormatElapsedSeconds(LONGLONG elapsedSeconds)
402
+{
403
+ using wsl::shared::Localization;
404
+
405
+ const auto [unit, count] = ClassifyElapsedSeconds(elapsedSeconds);
406
+ switch (unit)
407
+ {
408
+ case ElapsedUnit::LessThanASecond:
409
+ return Localization::WSLCCLI_RelativeTimeLessThanASecond();
410
+ case ElapsedUnit::OneSecond:
411
+ return Localization::WSLCCLI_RelativeTimeOneSecond();
412
+ case ElapsedUnit::Seconds:
413
+ return Localization::WSLCCLI_RelativeTimeSeconds(count);
414
+ case ElapsedUnit::AboutAMinute:
415
+ return Localization::WSLCCLI_RelativeTimeAboutAMinute();
416
+ case ElapsedUnit::Minutes:
417
+ return Localization::WSLCCLI_RelativeTimeMinutes(count);
418
+ case ElapsedUnit::AboutAnHour:
419
+ return Localization::WSLCCLI_RelativeTimeAboutAnHour();
420
+ case ElapsedUnit::Hours:
421
+ return Localization::WSLCCLI_RelativeTimeHours(count);
422
+ case ElapsedUnit::Days:
423
+ return Localization::WSLCCLI_RelativeTimeDays(count);
424
+ case ElapsedUnit::Weeks:
425
+ return Localization::WSLCCLI_RelativeTimeWeeks(count);
426
+ case ElapsedUnit::Months:
427
+ return Localization::WSLCCLI_RelativeTimeMonths(count);
428
+ case ElapsedUnit::Years:
429
+ return Localization::WSLCCLI_RelativeTimeYears(count);
430
+ default:
431
+ THROW_HR(E_UNEXPECTED);
432
+ }
433
+}
434
+
435
+std::wstring wsl::windows::common::timestamp::FormatInvariantElapsedSeconds(LONGLONG elapsedSeconds)
436
+{
437
+ const auto [unit, count] = ClassifyElapsedSeconds(elapsedSeconds);
438
+ switch (unit)
439
+ {
440
+ case ElapsedUnit::LessThanASecond:
441
+ return L"Less than a second ago";
442
+ case ElapsedUnit::OneSecond:
443
+ return L"1 second ago";
444
+ case ElapsedUnit::Seconds:
445
+ return std::format(L"{} seconds ago", count);
446
+ case ElapsedUnit::AboutAMinute:
447
+ return L"About a minute ago";
448
+ case ElapsedUnit::Minutes:
449
+ return std::format(L"{} minutes ago", count);
450
+ case ElapsedUnit::AboutAnHour:
451
+ return L"About an hour ago";
452
+ case ElapsedUnit::Hours:
453
+ return std::format(L"{} hours ago", count);
454
+ case ElapsedUnit::Days:
455
+ return std::format(L"{} days ago", count);
456
+ case ElapsedUnit::Weeks:
457
+ return std::format(L"{} weeks ago", count);
458
+ case ElapsedUnit::Months:
459
+ return std::format(L"{} months ago", count);
460
+ case ElapsedUnit::Years:
461
+ return std::format(L"{} years ago", count);
462
+ default:
463
+ THROW_HR(E_UNEXPECTED);
464
+ }
465
}
466
467
std::wstring wsl::windows::common::timestamp::FormatRelativeTime(LONGLONG timestamp)
@@ -381,3 +473,13 @@ std::wstring wsl::windows::common::timestamp::FormatRelativeTime(LONGLONG timest
473
474
return FormatElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - timestamp);
475
}
476
+
477
+std::wstring wsl::windows::common::timestamp::FormatInvariantRelativeTime(LONGLONG timestamp)
478
+{
479
+ if (timestamp == 0)
480
+ {
481
+ return {};
482
+ }
483
+
484
+ return FormatInvariantElapsedSeconds(static_cast<LONGLONG>(std::time(nullptr)) - timestamp);
485
+}
src/windows/common/timestamp.hpp
+8
@@ -49,8 +49,16 @@ std::string Rfc3339ToUtcDisplayTime(std::string_view timestamp);
49
// or "3 weeks". Negative values are treated as zero.
50
std::wstring FormatElapsedSeconds(LONGLONG elapsedSeconds);
51
52
+// The invariant English form of FormatElapsedSeconds, matching the strings docker produces through
53
+// go-units HumanDuration. Machine readable output uses this so its values do not vary by display
54
+// language.
55
+std::wstring FormatInvariantElapsedSeconds(LONGLONG elapsedSeconds);
56
+
57
// Renders how long ago a timestamp given in seconds since the unix epoch occurred. A timestamp of
58
// zero means "unset" and returns an empty string.
59
std::wstring FormatRelativeTime(LONGLONG timestamp);
60
61
+// The invariant English form of FormatRelativeTime.
62
+std::wstring FormatInvariantRelativeTime(LONGLONG timestamp);
63
+
64
} // namespace wsl::windows::common::timestamp
src/windows/inc/docker_schema.h
+4
-1
@@ -775,6 +775,8 @@ struct ContainerInfo
775
std::vector<std::string> Names;
776
std::string Image;
777
std::string ImageID;
778
+ std::string Command;
779
+ std::string Status;
780
std::map<std::string, std::string> Labels;
781
std::vector<Port> Ports;
782
std::vector<Mount> Mounts;
@@ -783,7 +785,8 @@ struct ContainerInfo
785
HostConfig HostConfig;
786
NetworkSettings NetworkSettings;
787
786
- NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerInfo, Id, Names, Image, ImageID, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings);
788
+ NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
789
+ ContainerInfo, Id, Names, Image, ImageID, Command, Status, Labels, Ports, Mounts, State, Created, HostConfig, NetworkSettings);
790
};
791
792
struct BuildKitVertex
src/windows/service/inc/wslc.idl
+8
@@ -372,9 +372,17 @@ typedef struct _WSLCContainerEntry
372
{
373
char Name[WSLC_MAX_CONTAINER_NAME_LENGTH + 1];
374
char Image[WSLC_MAX_IMAGE_NAME_LENGTH + 1];
375
+ // The runtime imposes no bound on these values, so they are allocated by the callee and freed
376
+ // by the caller. Any of them may be null when the container reports no value.
377
+ [string] LPSTR Command;
378
+ [string] LPSTR Status;
379
+ [string] LPSTR Labels;
380
+ [string] LPSTR Networks;
381
+ [string] LPSTR Mounts;
382
WSLCContainerId Id;
383
LONGLONG StateChangedAt;
384
LONGLONG CreatedAt;
385
+ ULONG LocalVolumes;
386
WSLCContainerState State;
387
} WSLCContainerEntry;
388
src/windows/wslc/services/ContainerModel.h
+39
-1
@@ -134,12 +134,50 @@ struct ContainerInformation
134
std::string Id;
135
std::string Name;
136
std::string Image;
137
+ // Command and runtime supplied status description.
138
+ std::string Command;
139
+ std::string Status;
140
+ std::string Labels;
141
+ std::string Networks;
142
+ std::string Mounts;
143
+ ULONG LocalVolumes{};
144
WSLCContainerState State;
145
LONGLONG StateChangedAt{};
146
LONGLONG CreatedAt{};
147
std::vector<PortInformation> Ports;
148
+};
149
+
150
+// The platform a container runs on. Emitted as a nested object to match docker.
151
+struct ContainerPlatform
152
+{
153
+ std::string architecture;
154
+ std::string os;
155
142
- NLOHMANN_DEFINE_TYPE_INTRUSIVE(ContainerInformation, Id, Name, Image, State, StateChangedAt, CreatedAt, Ports);
156
+ NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerPlatform, architecture, os);
157
+};
158
+
159
+// The shape emitted by "container list --format json".
160
+struct ContainerOutputInformation
161
+{
162
+ std::string Command;
163
+ std::string CreatedAt;
164
+ std::string HealthStatus;
165
+ std::string ID;
166
+ std::string Image;
167
+ std::string Labels;
168
+ std::string LocalVolumes;
169
+ std::string Mounts;
170
+ std::string Names;
171
+ std::string Networks;
172
+ ContainerPlatform Platform;
173
+ std::string Ports;
174
+ std::string RunningFor;
175
+ std::string Size;
176
+ std::string State;
177
+ std::string Status;
178
+
179
+ NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
180
+ ContainerOutputInformation, Command, CreatedAt, HealthStatus, ID, Image, Labels, LocalVolumes, Mounts, Names, Networks, Platform, Ports, RunningFor, Size, State, Status);
181
};
182
183
struct EnvironmentVariable
src/windows/wslc/services/ContainerService.cpp
+191
-13
@@ -21,6 +21,7 @@ Abstract:
21
#include <wslutil.h>
22
#include <HandleConsoleProgressBar.h>
23
#include <WSLCProcessLauncher.h>
24
+#include <WSLCContainerEntry.h>
25
#include <ConsoleState.h>
26
#include <CommandLine.h>
27
#include <WSLCUserSettings.h>
@@ -340,35 +341,206 @@ int ContainerService::Attach(Terminal& terminal, Session& session, const std::st
341
return runningProcess.Wait();
342
}
343
343
-std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt)
344
+// The invariant state name. This is what "container list --format json" reports.
345
+std::wstring ContainerService::ContainerStateName(WSLCContainerState state)
346
{
345
- std::wstring stateString;
347
switch (state)
348
{
349
case WSLCContainerState::WslcContainerStateCreated:
349
- stateString = L"created";
350
- break;
350
+ return L"created";
351
case WSLCContainerState::WslcContainerStateRunning:
352
- stateString = L"running";
353
- break;
352
+ return L"running";
353
case WSLCContainerState::WslcContainerStateDeleted:
355
- stateString = L"stopped";
356
- break;
354
+ return L"stopped";
355
case WSLCContainerState::WslcContainerStateExited:
358
- stateString = L"exited";
359
- break;
356
+ return L"exited";
357
case WSLCContainerState::WslcContainerStateInvalid:
358
return L"invalid";
359
default:
360
THROW_HR(E_UNEXPECTED);
361
}
362
+}
363
366
- if (stateChangedAt == 0)
364
+std::wstring ContainerService::LocalizedContainerStateName(WSLCContainerState state)
365
+{
366
+ switch (state)
367
+ {
368
+ case WSLCContainerState::WslcContainerStateCreated:
369
+ return Localization::WSLCCLI_ContainerStateCreated();
370
+ case WSLCContainerState::WslcContainerStateRunning:
371
+ return Localization::WSLCCLI_ContainerStateRunning();
372
+ case WSLCContainerState::WslcContainerStateDeleted:
373
+ return Localization::WSLCCLI_ContainerStateStopped();
374
+ case WSLCContainerState::WslcContainerStateExited:
375
+ return Localization::WSLCCLI_ContainerStateExited();
376
+ case WSLCContainerState::WslcContainerStateInvalid:
377
+ return Localization::WSLCCLI_ContainerStateInvalid();
378
+ default:
379
+ THROW_HR(E_UNEXPECTED);
380
+ }
381
+}
382
+
383
+std::wstring ContainerService::ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt, FormatType format)
384
+{
385
+ const auto invariant = format == FormatType::Json;
386
+ auto stateString = invariant ? ContainerStateName(state) : LocalizedContainerStateName(state);
387
+ if (stateChangedAt == 0 || state == WSLCContainerState::WslcContainerStateInvalid)
388
{
389
return stateString;
390
}
391
371
- return std::format(L"{} {}", stateString, wsl::windows::common::timestamp::FormatRelativeTime(stateChangedAt));
392
+ const auto relative = invariant ? wsl::windows::common::timestamp::FormatInvariantRelativeTime(stateChangedAt)
393
+ : wsl::windows::common::timestamp::FormatRelativeTime(stateChangedAt);
394
+
395
+ return std::format(L"{} {}", stateString, relative);
396
+}
397
+
398
+// Reports whether a code point is printable using the same rule as Go's unicode.IsPrint, which docker relies on when
399
+// quoting: letters, marks, numbers, punctuation, symbols and the ASCII space.
400
+static bool IsPrintable(UChar32 codePoint)
401
+{
402
+ constexpr auto printableMask = U_GC_L_MASK | U_GC_M_MASK | U_GC_N_MASK | U_GC_P_MASK | U_GC_S_MASK;
403
+ return codePoint == U' ' || (U_GET_GC_MASK(codePoint) & printableMask) != 0;
404
+}
405
+
406
+// Appends a code point that has no printable representation, mirroring the escapes Go's strconv.Quote emits.
407
+static void AppendEscape(std::wstring& quoted, UChar32 codePoint)
408
+{
409
+ switch (codePoint)
410
+ {
411
+ case L'\a':
412
+ quoted += L"\\a";
413
+ return;
414
+ case L'\b':
415
+ quoted += L"\\b";
416
+ return;
417
+ case L'\f':
418
+ quoted += L"\\f";
419
+ return;
420
+ case L'\n':
421
+ quoted += L"\\n";
422
+ return;
423
+ case L'\r':
424
+ quoted += L"\\r";
425
+ return;
426
+ case L'\t':
427
+ quoted += L"\\t";
428
+ return;
429
+ case L'\v':
430
+ quoted += L"\\v";
431
+ return;
432
+ default:
433
+ break;
434
+ }
435
+
436
+ if (codePoint < L' ' || codePoint == 0x7F)
437
+ {
438
+ quoted += std::format(L"\\x{:02x}", static_cast<unsigned int>(codePoint));
439
+ }
440
+ else if (U_IS_SURROGATE(codePoint))
441
+ {
442
+ // An unpaired surrogate is not a valid code point, and Go substitutes the replacement character.
443
+ quoted += L"\\ufffd";
444
+ }
445
+ else if (codePoint < 0x10000)
446
+ {
447
+ quoted += std::format(L"\\u{:04x}", static_cast<unsigned int>(codePoint));
448
+ }
449
+ else
450
+ {
451
+ quoted += std::format(L"\\U{:08x}", static_cast<unsigned int>(codePoint));
452
+ }
453
+}
454
+
455
+std::wstring ContainerService::FormatCommand(const std::string& command, bool truncate)
456
+{
457
+ constexpr size_t c_maxDisplayWidth = 20;
458
+
459
+ auto wide = wsl::shared::string::MultiByteToWide(command);
460
+ if (truncate)
461
+ {
462
+ wide = wsl::windows::common::string::Ellipsis(wide, c_maxDisplayWidth);
463
+ }
464
+
465
+ // Quoting happens after truncation, so the result can exceed c_maxDisplayWidth. This matches docker, which truncates
466
+ // the command first and quotes the truncated value.
467
+ const auto length = static_cast<int32_t>(wide.size());
468
+ std::wstring quoted{L'"'};
469
+ for (int32_t index = 0; index < length;)
470
+ {
471
+ const auto start = index;
472
+ UChar32 codePoint{};
473
+ U16_NEXT(wide.data(), index, length, codePoint);
474
+
475
+ if (codePoint == L'"' || codePoint == L'\\')
476
+ {
477
+ quoted += L'\\';
478
+ quoted += static_cast<wchar_t>(codePoint);
479
+ }
480
+ else if (IsPrintable(codePoint))
481
+ {
482
+ quoted.append(wide, start, static_cast<size_t>(index - start));
483
+ }
484
+ else
485
+ {
486
+ AppendEscape(quoted, codePoint);
487
+ }
488
+ }
489
+
490
+ quoted += L'"';
491
+ return quoted;
492
+}
493
+
494
+std::wstring ContainerService::FormatMounts(const std::string& mounts, bool truncate)
495
+{
496
+ constexpr size_t c_maxDisplayWidth = 15;
497
+
498
+ auto wide = wsl::shared::string::MultiByteToWide(mounts);
499
+ if (!truncate || wide.empty())
500
+ {
501
+ return wide;
502
+ }
503
+
504
+ std::vector<std::wstring> shortened;
505
+ for (const auto& mount : wsl::shared::string::SplitPreserveEmpty(std::wstring_view{wide}, L','))
506
+ {
507
+ shortened.emplace_back(wsl::windows::common::string::Ellipsis(mount, c_maxDisplayWidth));
508
+ }
509
+
510
+ return wsl::shared::string::Join(shortened, L',');
511
+}
512
+
513
+std::wstring ContainerService::FormatStatus(const std::string& status, WSLCContainerState state, LONGLONG stateChangedAt, FormatType format)
514
+{
515
+ if (!status.empty())
516
+ {
517
+ return wsl::shared::string::MultiByteToWide(status);
518
+ }
519
+
520
+ return ContainerStateToString(state, stateChangedAt, format);
521
+}
522
+
523
+std::string ContainerService::FormatHealthStatus(const std::string& status)
524
+{
525
+ const auto open = status.find('(');
526
+ if (open == std::string::npos || status.back() != ')')
527
+ {
528
+ return {};
529
+ }
530
+
531
+ constexpr std::string_view c_healthPrefix = "health: ";
532
+ auto health = std::string_view{status}.substr(open + 1, status.size() - open - 2);
533
+ if (health.starts_with(c_healthPrefix))
534
+ {
535
+ health.remove_prefix(c_healthPrefix.size());
536
+ }
537
+
538
+ if (health == "healthy" || health == "unhealthy" || health == "starting")
539
+ {
540
+ return std::string{health};
541
+ }
542
+
543
+ return {};
544
}
545
546
std::wstring ContainerService::FormatPorts(WSLCContainerState state, const std::vector<PortInformation>& ports)
@@ -535,7 +707,7 @@ std::vector<ContainerInformation> ContainerService::List(
707
options.Filters = filterEntries.data();
708
options.FiltersCount = static_cast<ULONG>(filterEntries.size());
709
538
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
710
+ wsl::windows::common::wslc::unique_container_entry_array containers;
711
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
712
THROW_IF_FAILED(
713
session.Get()->ListContainers(&options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -547,6 +719,12 @@ std::vector<ContainerInformation> ContainerService::List(
719
ContainerInformation entry;
720
entry.Name = current.Name;
721
entry.Image = current.Image;
722
+ entry.Command = current.Command == nullptr ? "" : current.Command;
723
+ entry.Status = current.Status == nullptr ? "" : current.Status;
724
+ entry.Labels = current.Labels == nullptr ? "" : current.Labels;
725
+ entry.Networks = current.Networks == nullptr ? "" : current.Networks;
726
+ entry.Mounts = current.Mounts == nullptr ? "" : current.Mounts;
727
+ entry.LocalVolumes = current.LocalVolumes;
728
entry.State = current.State;
729
entry.Id = current.Id;
730
entry.StateChangedAt = current.StateChangedAt;
src/windows/wslc/services/ContainerService.h
+26
-1
@@ -22,8 +22,33 @@ Abstract:
22
namespace wsl::windows::wslc::services {
23
struct ContainerService
24
{
25
- static std::wstring ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt = 0);
25
+ // Renders a container state with the time it last changed appended. Table output is localized;
26
+ // json output is invariant so its values do not vary by display language.
27
+ static std::wstring ContainerStateToString(WSLCContainerState state, LONGLONG stateChangedAt = 0, models::FormatType format = models::FormatType::Table);
28
+
29
+ // The bare state name, e.g. "running", without the relative time ContainerStateToString appends.
30
+ static std::wstring ContainerStateName(WSLCContainerState state);
31
+
32
+ // The display form of ContainerStateName, used for the table output.
33
+ static std::wstring LocalizedContainerStateName(WSLCContainerState state);
34
+
35
static std::wstring FormatPorts(WSLCContainerState state, const std::vector<models::PortInformation>& ports);
36
+
37
+ static std::wstring FormatCommand(const std::string& command, bool truncate);
38
+
39
+ // Renders the comma separated mount list. Docker shortens each name independently, so a long path
40
+ // never crowds out the mounts that follow it.
41
+ static std::wstring FormatMounts(const std::string& mounts, bool truncate);
42
+
43
+ // Renders a container status, preferring the description supplied by the runtime and falling back
44
+ // to a locally built one when it is unavailable. Only the fallback varies with the format, since
45
+ // the runtime supplied description is already invariant.
46
+ static std::wstring FormatStatus(
47
+ const std::string& status, WSLCContainerState state, LONGLONG stateChangedAt, models::FormatType format = models::FormatType::Table);
48
+
49
+ // Extracts the health status from a runtime supplied status description, which carries it as a
50
+ // parenthesized suffix. Containers without a health check report an empty string.
51
+ static std::string FormatHealthStatus(const std::string& status);
52
static int Attach(Terminal& terminal, models::Session& session, const std::string& id);
53
static int Run(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options);
54
static models::CreateContainerResult Create(Terminal& terminal, models::Session& session, const std::string& image, models::ContainerOptions options);
src/windows/wslc/tasks/ContainerTasks.cpp
+52
-15
@@ -125,6 +125,38 @@ nlohmann::json ComputeContainerStatsJson(const wsl::windows::common::docker_sche
125
};
126
}
127
128
+// Builds the representation of a container, shared by the table and json output so the two cannot
129
+// drift. Every value is emitted as a string apart from the platform object, and the id is truncated
130
+// unless --no-trunc is passed. RunningFor and Status are the only fields that vary with the format:
131
+// docker renders them in invariant English, so json keeps that while the table is localized.
132
+ContainerOutputInformation ToContainerOutput(const ContainerInformation& container, bool truncate, FormatType format)
133
+{
134
+ ContainerOutputInformation entry;
135
+ entry.Command = WideToMultiByte(ContainerService::FormatCommand(container.Command, truncate));
136
+ entry.CreatedAt = EpochToLocalDisplayTime(container.CreatedAt);
137
+ // The runtime reports health as a suffix on the status description, which is the only place it is
138
+ // exposed by the listing API.
139
+ entry.HealthStatus = ContainerService::FormatHealthStatus(container.Status);
140
+ entry.ID = truncate ? TruncateId(container.Id) : container.Id;
141
+ entry.Image = container.Image;
142
+ entry.Labels = container.Labels;
143
+ entry.LocalVolumes = std::to_string(container.LocalVolumes);
144
+ entry.Mounts = WideToMultiByte(ContainerService::FormatMounts(container.Mounts, truncate));
145
+ entry.Names = container.Name;
146
+ entry.Networks = container.Networks;
147
+ entry.Platform.architecture = wsl::shared::Arm64 ? "arm64" : "amd64";
148
+ entry.Platform.os = "linux";
149
+ entry.Ports = WideToMultiByte(ContainerService::FormatPorts(container.State, container.Ports));
150
+ entry.RunningFor = WideToMultiByte(
151
+ format == FormatType::Json ? FormatInvariantRelativeTime(container.CreatedAt) : FormatRelativeTime(container.CreatedAt));
152
+ // Container sizes are only computed when docker is passed --size, which wslc does not support.
153
+ entry.Size = WideToMultiByte(FormatHumanReadableSize(0));
154
+ entry.State = WideToMultiByte(ContainerService::ContainerStateName(container.State));
155
+ entry.Status = WideToMultiByte(ContainerService::FormatStatus(container.Status, container.State, container.StateChangedAt, format));
156
+
157
+ return entry;
158
+}
159
+
160
} // namespace
161
162
namespace wsl::windows::wslc::task {
@@ -535,15 +567,17 @@ void ListContainers(CLIExecutionContext& context)
567
if (context.Args.GetValue<ArgType::Quiet>())
568
{
569
// Print only the container ids
570
+ bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
571
for (const auto& container : containers)
572
{
540
- context.Terminal.Output(L"{}\n", MultiByteToWide(container.Id));
573
+ context.Terminal.Output(L"{}\n", MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id));
574
}
575
576
return;
577
}
578
579
const auto format = context.Args.GetValue<ArgType::Format>(FormatType::Table);
580
+ bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
581
582
switch (format)
583
{
@@ -551,45 +585,48 @@ void ListContainers(CLIExecutionContext& context)
585
{
586
for (const auto& container : containers)
587
{
554
- context.Terminal.Output(L"{}\n", ToJsonW(container, c_jsonCompactIndent));
588
+ context.Terminal.Output(L"{}\n", ToJsonW(ToContainerOutput(container, trunc, FormatType::Json), c_jsonCompactIndent));
589
}
590
591
break;
592
}
593
case FormatType::Table:
594
{
561
- bool trunc = !context.Args.GetValue<ArgType::NoTrunc>();
595
using enum ColumnOverflow;
596
597
// Create table with or without column limits based on --no-trunc flag
565
- auto table = trunc ? wsl::windows::wslc::TableOutput<6>(
598
+ auto table = trunc ? wsl::windows::wslc::TableOutput<7>(
599
context.Terminal,
600
{{{Localization::WSLCCLI_TableHeaderContainerId(), {.MaxWidth = 12, .Overflow = Shrink}},
568
- {Localization::WSLCCLI_TableHeaderName(), {.MaxWidth = 20, .Overflow = Shrink}},
601
{Localization::WSLCCLI_TableHeaderImage(), {.MaxWidth = 20, .Overflow = Shrink}},
602
+ {Localization::WSLCCLI_TableHeaderCommand(), {.Overflow = Shrink}},
603
{Localization::WSLCCLI_TableHeaderCreated(), {.Overflow = Shrink}},
604
{Localization::WSLCCLI_TableHeaderStatus(), {.Overflow = Shrink}},
572
- {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}}}},
605
+ {Localization::WSLCCLI_TableHeaderPorts(), {.Overflow = Shrink}},
606
+ {Localization::WSLCCLI_TableHeaderNames(), {.MaxWidth = 20, .Overflow = Shrink}}}},
607
containers.size())
574
- : wsl::windows::wslc::TableOutput<6>(
608
+ : wsl::windows::wslc::TableOutput<7>(
609
context.Terminal,
610
{Localization::WSLCCLI_TableHeaderContainerId(),
577
- Localization::WSLCCLI_TableHeaderName(),
611
Localization::WSLCCLI_TableHeaderImage(),
612
+ Localization::WSLCCLI_TableHeaderCommand(),
613
Localization::WSLCCLI_TableHeaderCreated(),
614
Localization::WSLCCLI_TableHeaderStatus(),
581
- Localization::WSLCCLI_TableHeaderPorts()});
615
+ Localization::WSLCCLI_TableHeaderPorts(),
616
+ Localization::WSLCCLI_TableHeaderNames()});
617
618
// Add each container as a row
619
for (const auto& container : containers)
620
{
621
+ const auto entry = ToContainerOutput(container, trunc, FormatType::Table);
622
table.WriteRow({
587
- MultiByteToWide(trunc ? TruncateId(container.Id) : container.Id),
588
- MultiByteToWide(container.Name),
589
- MultiByteToWide(container.Image),
590
- FormatRelativeTime(container.CreatedAt),
591
- ContainerService::ContainerStateToString(container.State, container.StateChangedAt),
592
- ContainerService::FormatPorts(container.State, container.Ports),
623
+ MultiByteToWide(entry.ID),
624
+ MultiByteToWide(entry.Image),
625
+ MultiByteToWide(entry.Command),
626
+ MultiByteToWide(entry.RunningFor),
627
+ MultiByteToWide(entry.Status),
628
+ MultiByteToWide(entry.Ports),
629
+ MultiByteToWide(entry.Names),
630
});
631
}
632
src/windows/wslcsession/WSLCSession.cpp
+60
-3
@@ -24,6 +24,7 @@ Abstract:
24
#include "WSLCSessionDefaults.h"
25
#include "wslpolicies.h"
26
#include "APICompat.h"
27
+#include "WSLCContainerEntry.h"
28
29
using namespace wsl::windows::common;
30
using io::MultiHandleWait;
@@ -2548,6 +2549,13 @@ try
2549
// if some IDs returned by Docker aren't in m_containers (e.g. created externally), but in the
2550
// common case the two should match.
2551
auto output = wil::make_unique_cotaskmem<WSLCContainerEntry[]>(dockerContainers.size());
2552
+ auto freeStrings = wil::scope_exit([&] {
2553
+ for (size_t i = 0; i < dockerContainers.size(); ++i)
2554
+ {
2555
+ wsl::windows::common::wslc::FreeContainerEntryStrings(&output[i]);
2556
+ }
2557
+ });
2558
+
2559
std::vector<WSLCContainerPortMapping> allPorts;
2560
2561
size_t index = 0;
@@ -2563,6 +2571,47 @@ try
2571
THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Image, e->Image().c_str()) != 0);
2572
THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Name, e->Name().c_str()) != 0);
2573
THROW_HR_IF(E_UNEXPECTED, strcpy_s(output[index].Id, e->ID().c_str()) != 0);
2574
+
2575
+ // Commands and status descriptions have no bound imposed by the runtime, so they are
2576
+ // allocated rather than copied into a fixed buffer.
2577
+ output[index].Command = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(dockerContainer.Command.c_str()).release();
2578
+ output[index].Status = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(dockerContainer.Status.c_str()).release();
2579
+
2580
+ // Labels, networks and mounts are reported the way the docker CLI renders them: a comma
2581
+ // separated list. Like the command and status above they are unbounded.
2582
+ std::vector<std::string> labels;
2583
+ for (const auto& [key, value] : dockerContainer.Labels)
2584
+ {
2585
+ labels.push_back(std::format("{}={}", key, value));
2586
+ }
2587
+
2588
+ std::vector<std::string> networks;
2589
+ for (const auto& [name, _] : dockerContainer.NetworkSettings.Networks)
2590
+ {
2591
+ networks.push_back(name);
2592
+ }
2593
+
2594
+ std::vector<std::string> mounts;
2595
+ ULONG localVolumes = 0;
2596
+ for (const auto& mount : dockerContainer.Mounts)
2597
+ {
2598
+ // Named volumes report a name, bind mounts only report the host path.
2599
+ mounts.push_back(mount.Name.empty() ? mount.Source : mount.Name);
2600
+ if (mount.Type == "volume")
2601
+ {
2602
+ localVolumes++;
2603
+ }
2604
+ }
2605
+
2606
+ const auto joinedLabels = wsl::shared::string::Join(labels, ',');
2607
+ const auto joinedNetworks = wsl::shared::string::Join(networks, ',');
2608
+ const auto joinedMounts = wsl::shared::string::Join(mounts, ',');
2609
+
2610
+ output[index].Labels = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(joinedLabels.c_str()).release();
2611
+ output[index].Networks = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(joinedNetworks.c_str()).release();
2612
+ output[index].Mounts = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(joinedMounts.c_str()).release();
2613
+ output[index].LocalVolumes = localVolumes;
2614
+
2615
e->GetState(&output[index].State);
2616
e->GetStateChangedAt(&output[index].StateChangedAt);
2617
e->GetCreatedAt(&output[index].CreatedAt);
@@ -2583,13 +2632,21 @@ try
2632
index++;
2633
}
2634
2635
+ // Finish every allocation before transferring ownership so nothing can throw once the caller
2636
+ // owns the results.
2637
+ wil::unique_cotaskmem_ptr<WSLCContainerPortMapping[]> portsOutput;
2638
+ if (!allPorts.empty())
2639
+ {
2640
+ portsOutput = wil::make_unique_cotaskmem<WSLCContainerPortMapping[]>(allPorts.size());
2641
+ memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping));
2642
+ }
2643
+
2644
+ freeStrings.release();
2645
*Count = static_cast<ULONG>(index);
2646
*Containers = output.release();
2647
2589
- if (!allPorts.empty())
2648
+ if (portsOutput)
2649
{
2591
- auto portsOutput = wil::make_unique_cotaskmem<WSLCContainerPortMapping[]>(allPorts.size());
2592
- memcpy(portsOutput.get(), allPorts.data(), allPorts.size() * sizeof(WSLCContainerPortMapping));
2650
*PortsCount = static_cast<ULONG>(allPorts.size());
2651
*Ports = portsOutput.release();
2652
}
test/windows/StringUnitTests.cpp
+54
@@ -5,6 +5,7 @@
5
#include "string.hpp"
6
7
using wsl::windows::common::string::c_reclaimedSpacePrecision;
8
+using wsl::windows::common::string::Ellipsis;
9
using wsl::windows::common::string::FormatHumanReadableSize;
10
using wsl::windows::common::string::ParseStorageSize;
11
using wsl::windows::common::string::StorageSizeUnit;
@@ -268,6 +269,59 @@ class StringUnitTests
269
}
270
}
271
272
+ // Docker shortens display values with formatter.Ellipsis, which measures terminal columns rather than
273
+ // characters so East Asian wide and fullwidth code points count double.
274
+ TEST_METHOD(Ellipsis_NarrowCharacters_AreCountedAsOneColumn)
275
+ {
276
+ VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"", 20));
277
+ VERIFY_ARE_EQUAL(std::wstring{L"sleep 3600"}, Ellipsis(L"sleep 3600", 20));
278
+ VERIFY_ARE_EQUAL(std::wstring{L"12345678901234567890"}, Ellipsis(L"12345678901234567890", 20));
279
+ VERIFY_ARE_EQUAL(std::wstring{L"1234567890123456789\u2026"}, Ellipsis(L"123456789012345678901", 20));
280
+ VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0') + L"\u2026", Ellipsis(std::wstring(21, L'\u00E0'), 20));
281
+ }
282
+
283
+ TEST_METHOD(Ellipsis_WideCharacters_AreCountedAsTwoColumns)
284
+ {
285
+ // Ten wide characters fill the twenty columns exactly, so an eleventh forces the value to be shortened
286
+ // to the nine characters that leave room for the ellipsis.
287
+ VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5'), Ellipsis(std::wstring(10, L'\u65E5'), 20));
288
+ VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5') + L"\u2026", Ellipsis(std::wstring(11, L'\u65E5'), 20));
289
+ VERIFY_ARE_EQUAL(std::wstring(9, L'\uFF21') + L"\u2026", Ellipsis(std::wstring(11, L'\uFF21'), 20));
290
+ VERIFY_ARE_EQUAL(std::wstring{L"ab"} + std::wstring(8, L'\u65E5') + L"\u2026", Ellipsis(L"ab" + std::wstring(10, L'\u65E5'), 20));
291
+ }
292
+
293
+ TEST_METHOD(Ellipsis_SurrogatePairs_AreNotSplit)
294
+ {
295
+ // Emoji are wide and encoded as surrogate pairs, so both the column count and the code unit boundary
296
+ // have to be honored.
297
+ const std::wstring emoji{L"\U0001F600"};
298
+ std::wstring ten;
299
+ for (size_t index = 0; index < 10; ++index)
300
+ {
301
+ ten += emoji;
302
+ }
303
+
304
+ VERIFY_ARE_EQUAL(ten, Ellipsis(ten, 20));
305
+ VERIFY_ARE_EQUAL(ten.substr(0, 18) + L"\u2026", Ellipsis(ten + emoji, 20));
306
+ }
307
+
308
+ TEST_METHOD(Ellipsis_SmallWidths_MatchDocker)
309
+ {
310
+ VERIFY_ARE_EQUAL(std::wstring{L""}, Ellipsis(L"abc", 0));
311
+
312
+ // A width of one has no room for both content and an ellipsis, so the leading code point is kept even
313
+ // when it is wider than the limit.
314
+ VERIFY_ARE_EQUAL(std::wstring{L"a"}, Ellipsis(L"abc", 1));
315
+ VERIFY_ARE_EQUAL(std::wstring{L"\u65E5"}, Ellipsis(L"\u65E5\u65E5", 1));
316
+ VERIFY_ARE_EQUAL(std::wstring{L"\U0001F600"}, Ellipsis(L"\U0001F600\U0001F600", 1));
317
+
318
+ // A leading wide character leaves no room for the ellipsis at a width of two, and docker returns the
319
+ // value untouched in that case.
320
+ VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u65E5"}, Ellipsis(L"\u65E5\u65E5", 2));
321
+ VERIFY_ARE_EQUAL(std::wstring{L"a\u2026"}, Ellipsis(L"abc", 2));
322
+ VERIFY_ARE_EQUAL(std::wstring{L"\u65E5\u2026"}, Ellipsis(L"\u65E5\u65E5", 3));
323
+ }
324
+
325
TEST_METHOD(StorageSize_BytesToTextRoundTrips)
326
{
327
// The parser accepts suffixes up to peta, matching docker's unit map, so the round trip is
test/windows/WSLCTests.cpp
+11
-10
@@ -18,6 +18,7 @@ Abstract:
18
#include "wslccompat.h"
19
#include "WSLCProcessLauncher.h"
20
#include "WSLCContainerLauncher.h"
21
+#include "WSLCContainerEntry.h"
22
#include "WslCoreFilesystem.h"
23
#include "hcs.hpp"
24
#include "ContainerNameGenerator.h"
@@ -222,7 +223,7 @@ class WSLCTests
223
224
struct ListContainersResult
225
{
225
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> Containers;
226
+ wsl::windows::common::wslc::unique_container_entry_array Containers;
227
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> Ports;
228
};
229
@@ -7666,7 +7667,7 @@ class WSLCTests
7667
options.Filters = filters.data();
7668
options.FiltersCount = static_cast<ULONG>(filters.size());
7669
7669
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
7670
+ wsl::windows::common::wslc::unique_container_entry_array containers;
7671
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
7672
VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
7673
&options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -7751,7 +7752,7 @@ class WSLCTests
7752
options.Flags = WSLCListContainersFlagsAll;
7753
options.Limit = 1;
7754
7754
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
7755
+ wsl::windows::common::wslc::unique_container_entry_array containers;
7756
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
7757
VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
7758
&options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -7822,7 +7823,7 @@ class WSLCTests
7823
WSLCListContainersOptions options{};
7824
options.Flags = WSLCListContainersFlagsAll;
7825
7825
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
7826
+ wsl::windows::common::wslc::unique_container_entry_array containers;
7827
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
7828
HRESULT hrList = m_defaultSession->ListContainers(
7829
&options, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>());
@@ -9575,7 +9576,7 @@ class WSLCTests
9576
9577
// Verify that ListContainers returns the port data for a running container.
9578
{
9578
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
9579
+ wsl::windows::common::wslc::unique_container_entry_array containers;
9580
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
9581
VERIFY_SUCCEEDED(session.ListContainers(
9582
nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -9620,7 +9621,7 @@ class WSLCTests
9621
9622
auto createdContainer = createdLauncher.Create(session);
9623
9623
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
9624
+ wsl::windows::common::wslc::unique_container_entry_array containers;
9625
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
9626
VERIFY_SUCCEEDED(session.ListContainers(
9627
nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -9647,7 +9648,7 @@ class WSLCTests
9648
// Verify that a stopped container returns no ports.
9649
VERIFY_SUCCEEDED(container.Get().Stop(WSLCSignalSIGKILL, 0));
9650
{
9650
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
9651
+ wsl::windows::common::wslc::unique_container_entry_array containers;
9652
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
9653
VERIFY_SUCCEEDED(session.ListContainers(
9654
nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -12327,7 +12328,7 @@ class WSLCTests
12328
VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove", ¬Found), WSLC_E_CONTAINER_NOT_FOUND);
12329
VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer(id.c_str(), ¬Found), WSLC_E_CONTAINER_NOT_FOUND);
12330
12330
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
12331
+ wsl::windows::common::wslc::unique_container_entry_array containers;
12332
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
12333
VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
12334
nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -12362,7 +12363,7 @@ class WSLCTests
12363
wil::com_ptr<IWSLCContainer> notFound;
12364
VERIFY_ARE_EQUAL(m_defaultSession->OpenContainer("test-auto-remove-stdout", ¬Found), WSLC_E_CONTAINER_NOT_FOUND);
12365
12365
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
12366
+ wsl::windows::common::wslc::unique_container_entry_array containers;
12367
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
12368
VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
12369
nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
@@ -12493,7 +12494,7 @@ class WSLCTests
12494
// Validate that various operations can be done while the export is in progress.
12495
12496
{
12496
- wil::unique_cotaskmem_array_ptr<WSLCContainerEntry> containers;
12497
+ wsl::windows::common::wslc::unique_container_entry_array containers;
12498
wil::unique_cotaskmem_array_ptr<WSLCContainerPortMapping> ports;
12499
VERIFY_SUCCEEDED(m_defaultSession->ListContainers(
12500
nullptr, &containers, containers.size_address<ULONG>(), &ports, ports.size_address<ULONG>()));
test/windows/wslc/WSLCCLIContainerCommandUnitTests.cpp
new
+271
@@ -0,0 +1,271 @@
1
+// Copyright (C) Microsoft Corporation. All rights reserved.
2
+
3
+#include "precomp.h"
4
+#include "windows/Common.h"
5
+#include "WSLCCLITestHelpers.h"
6
+
7
+#include "ContainerService.h"
8
+
9
+using namespace wsl::windows::wslc;
10
+using namespace wsl::windows::wslc::services;
11
+using namespace WSLCTestHelpers;
12
+using namespace WEX::Logging;
13
+using namespace WEX::Common;
14
+using namespace WEX::TestExecution;
15
+
16
+namespace WSLCCLIContainerCommandUnitTests {
17
+
18
+class WSLCCLIContainerCommandUnitTests
19
+{
20
+ WSLC_TEST_CLASS(WSLCCLIContainerCommandUnitTests)
21
+
22
+ TEST_CLASS_SETUP(TestClassSetup)
23
+ {
24
+ return true;
25
+ }
26
+
27
+ TEST_CLASS_CLEANUP(TestClassCleanup)
28
+ {
29
+ return true;
30
+ }
31
+
32
+ static std::wstring Truncated(const std::string& command)
33
+ {
34
+ return ContainerService::FormatCommand(command, true);
35
+ }
36
+
37
+ TEST_METHOD(FormatCommand_Empty_ReturnsEmptyQuotes)
38
+ {
39
+ VERIFY_ARE_EQUAL(std::wstring{LR"("")"}, Truncated(""));
40
+ }
41
+
42
+ TEST_METHOD(FormatCommand_ShortCommand_IsQuotedUnchanged)
43
+ {
44
+ VERIFY_ARE_EQUAL(std::wstring{LR"("sleep 3600")"}, Truncated("sleep 3600"));
45
+ }
46
+
47
+ TEST_METHOD(FormatCommand_ExactlyTwentyCharacters_IsNotShortened)
48
+ {
49
+ VERIFY_ARE_EQUAL(std::wstring{LR"("12345678901234567890")"}, Truncated("12345678901234567890"));
50
+ }
51
+
52
+ TEST_METHOD(FormatCommand_TwentyOneCharacters_KeepsNineteenAndAppendsEllipsis)
53
+ {
54
+ VERIFY_ARE_EQUAL(std::wstring{L"\"1234567890123456789\u2026\""}, Truncated("123456789012345678901"));
55
+ }
56
+
57
+ TEST_METHOD(FormatCommand_LongCommand_MatchesDockerOutput)
58
+ {
59
+ VERIFY_ARE_EQUAL(std::wstring{L"\"sh -c 'echo this is\u2026\""}, Truncated("sh -c 'echo this is a very long command that should be truncated by docker'"));
60
+ }
61
+
62
+ TEST_METHOD(FormatCommand_NoTruncate_KeepsFullCommand)
63
+ {
64
+ const std::string command = "sh -c 'echo this is a very long command that should be truncated by docker'";
65
+ VERIFY_ARE_EQUAL(
66
+ std::wstring{L"\"" + wsl::shared::string::MultiByteToWide(command) + L"\""}, ContainerService::FormatCommand(command, false));
67
+ }
68
+
69
+ TEST_METHOD(FormatCommand_EmbeddedQuotesAndBackslashes_AreEscaped)
70
+ {
71
+ // Raw string literals are avoided here: the compiler mangles them when the verify macro
72
+ // stringizes its arguments.
73
+ VERIFY_ARE_EQUAL(std::wstring{L"\"say \\\"hi\\\"\""}, Truncated("say \"hi\""));
74
+ VERIFY_ARE_EQUAL(std::wstring{L"\"c:\\\\temp\""}, Truncated("c:\\temp"));
75
+ }
76
+
77
+ TEST_METHOD(FormatCommand_NarrowMultiByteCharacters_CountedAsOneColumn)
78
+ {
79
+ const std::string accented = "\xC3\xA0";
80
+ std::string twenty;
81
+ for (int i = 0; i < 20; ++i)
82
+ {
83
+ twenty += accented;
84
+ }
85
+
86
+ VERIFY_ARE_EQUAL(std::wstring(20, L'\u00E0').insert(0, L"\"") + L"\"", Truncated(twenty));
87
+ VERIFY_ARE_EQUAL(std::wstring(19, L'\u00E0').insert(0, L"\"") + L"\u2026\"", Truncated(twenty + accented));
88
+ }
89
+
90
+ TEST_METHOD(FormatCommand_WideCharacters_CountedAsTwoColumns)
91
+ {
92
+ // Docker measures display columns, so an East Asian wide character consumes two of the twenty
93
+ // available columns and only ten of them fit.
94
+ const std::string wide = "\xE6\x97\xA5";
95
+ std::string ten;
96
+ for (int i = 0; i < 10; ++i)
97
+ {
98
+ ten += wide;
99
+ }
100
+
101
+ VERIFY_ARE_EQUAL(std::wstring(10, L'\u65E5').insert(0, L"\"") + L"\"", Truncated(ten));
102
+ VERIFY_ARE_EQUAL(std::wstring(9, L'\u65E5').insert(0, L"\"") + L"\u2026\"", Truncated(ten + wide));
103
+ }
104
+
105
+ TEST_METHOD(FormatCommand_MixedWidthCharacters_AreShortenedByColumn)
106
+ {
107
+ // Two narrow characters leave eighteen columns, so eight wide characters fit before the ellipsis.
108
+ const std::string wide = "\xE6\x97\xA5";
109
+ std::string command = "ab";
110
+ for (int i = 0; i < 10; ++i)
111
+ {
112
+ command += wide;
113
+ }
114
+
115
+ VERIFY_ARE_EQUAL(std::wstring{L"\"ab"} + std::wstring(8, L'\u65E5') + L"\u2026\"", Truncated(command));
116
+ }
117
+
118
+ TEST_METHOD(FormatCommand_SurrogatePairs_AreNotSplit)
119
+ {
120
+ // Emoji are wide and encoded as surrogate pairs, so a shortened value has to stop on a code point
121
+ // boundary as well as a column boundary.
122
+ const std::string emoji = "\xF0\x9F\x98\x80";
123
+ std::string ten;
124
+ std::wstring expected;
125
+ for (int i = 0; i < 10; ++i)
126
+ {
127
+ ten += emoji;
128
+ expected += L"\U0001F600";
129
+ }
130
+
131
+ VERIFY_ARE_EQUAL(L"\"" + expected + L"\"", Truncated(ten));
132
+ VERIFY_ARE_EQUAL(L"\"" + expected.substr(0, 18) + L"\u2026\"", Truncated(ten + emoji));
133
+ }
134
+
135
+ TEST_METHOD(FormatCommand_ControlCharacters_AreEscaped)
136
+ {
137
+ // Docker quotes this field with Go's strconv.Quote, which renders control characters as escape sequences
138
+ // rather than emitting them raw and breaking the table row.
139
+ VERIFY_ARE_EQUAL(std::wstring{L"\"line1\\nline2\""}, Truncated("line1\nline2"));
140
+ VERIFY_ARE_EQUAL(std::wstring{L"\"col1\\tcol2\""}, Truncated("col1\tcol2"));
141
+ VERIFY_ARE_EQUAL(std::wstring{L"\"a\\rb\""}, Truncated("a\rb"));
142
+ VERIFY_ARE_EQUAL(std::wstring{L"\"a\\vb\""}, Truncated("a\vb"));
143
+ VERIFY_ARE_EQUAL(std::wstring{L"\"a\\fb\""}, Truncated("a\fb"));
144
+ VERIFY_ARE_EQUAL(std::wstring{L"\"a\\bb\""}, Truncated("a\bb"));
145
+ VERIFY_ARE_EQUAL(std::wstring{L"\"a\\ab\""}, Truncated("a\ab"));
146
+ }
147
+
148
+ TEST_METHOD(FormatCommand_NonPrintableCharacters_AreEscapedAsHex)
149
+ {
150
+ // Control characters without a dedicated escape use \x, matching strconv.Quote.
151
+ VERIFY_ARE_EQUAL(
152
+ std::wstring{L"\"a\\x1bb\""},
153
+ Truncated("a\x1b"
154
+ "b"));
155
+ VERIFY_ARE_EQUAL(
156
+ std::wstring{L"\"a\\x7fb\""},
157
+ Truncated("a\x7f"
158
+ "b"));
159
+ }
160
+
161
+ TEST_METHOD(FormatCommand_PrintableUnicode_IsNotEscaped)
162
+ {
163
+ // Letters and symbols stay verbatim, so only genuinely unprintable values are expanded.
164
+ VERIFY_ARE_EQUAL(std::wstring{L"\"caf\u00E9\""}, Truncated("caf\xC3\xA9"));
165
+ }
166
+
167
+ TEST_METHOD(FormatCommand_EscapesAreCountedAfterTruncation)
168
+ {
169
+ // Docker truncates before quoting, so escape expansion does not consume the display budget and the
170
+ // quoted result is wider than the limit.
171
+ VERIFY_ARE_EQUAL(std::wstring{L"\"\\n\\n\\n\\n\\n\""}, Truncated("\n\n\n\n\n"));
172
+ }
173
+
174
+ TEST_METHOD(FormatMounts_LongNames_AreShortenedIndependently)
175
+ {
176
+ // Docker shortens every mount name to fifteen columns rather than the list as a whole
177
+ // (ContainerContext.Mounts in cli/command/formatter/container.go).
178
+ const auto mounts = "/var/lib/docker/volumes/data,logs,/mnt/c/users/test/source";
179
+ VERIFY_ARE_EQUAL(std::wstring{L"/var/lib/docke\u2026,logs,/mnt/c/users/t\u2026"}, ContainerService::FormatMounts(mounts, true));
180
+ VERIFY_ARE_EQUAL(wsl::shared::string::MultiByteToWide(mounts), ContainerService::FormatMounts(mounts, false));
181
+ }
182
+
183
+ TEST_METHOD(FormatMounts_ShortNames_AreUnchanged)
184
+ {
185
+ VERIFY_ARE_EQUAL(std::wstring{L""}, ContainerService::FormatMounts("", true));
186
+ VERIFY_ARE_EQUAL(std::wstring{L"data-volume"}, ContainerService::FormatMounts("data-volume", true));
187
+ VERIFY_ARE_EQUAL(std::wstring{L"123456789012345"}, ContainerService::FormatMounts("123456789012345", true));
188
+ VERIFY_ARE_EQUAL(std::wstring{L"12345678901234\u2026"}, ContainerService::FormatMounts("1234567890123456", true));
189
+ }
190
+
191
+ TEST_METHOD(FormatMounts_WideCharacters_CountedAsTwoColumns)
192
+ {
193
+ // Seven wide characters occupy fourteen columns, so an eighth exceeds the fifteen column budget.
194
+ std::string wide;
195
+ for (int i = 0; i < 8; ++i)
196
+ {
197
+ wide += "\xE6\x97\xA5";
198
+ }
199
+
200
+ VERIFY_ARE_EQUAL(std::wstring(7, L'\u65E5') + L"\u2026", ContainerService::FormatMounts(wide, true));
201
+ }
202
+
203
+ TEST_METHOD(FormatStatus_RuntimeStatus_IsPreferred)
204
+ {
205
+ VERIFY_ARE_EQUAL(std::wstring{L"Up 5 minutes"}, ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0));
206
+ }
207
+
208
+ TEST_METHOD(FormatStatus_EmptyRuntimeStatus_FallsBackToState)
209
+ {
210
+ VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0));
211
+ }
212
+
213
+ // The fallback is built locally, so json has to render it in invariant English rather than in the
214
+ // machine's display language.
215
+ TEST_METHOD(FormatStatus_EmptyRuntimeStatus_JsonFallbackIsInvariant)
216
+ {
217
+ const auto twoHoursAgo = static_cast<LONGLONG>(std::time(nullptr)) - (2 * 60 * 60);
218
+
219
+ VERIFY_ARE_EQUAL(
220
+ std::wstring{L"exited 2 hours ago"},
221
+ ContainerService::FormatStatus("", WslcContainerStateExited, twoHoursAgo, models::FormatType::Json));
222
+ VERIFY_ARE_EQUAL(std::wstring{L"created"}, ContainerService::FormatStatus("", WslcContainerStateCreated, 0, models::FormatType::Json));
223
+ }
224
+
225
+ // A status supplied by the runtime is already invariant, so it is passed through unchanged for
226
+ // both formats.
227
+ TEST_METHOD(FormatStatus_RuntimeStatus_IsFormatIndependent)
228
+ {
229
+ VERIFY_ARE_EQUAL(
230
+ std::wstring{L"Up 5 minutes"},
231
+ ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0, models::FormatType::Json));
232
+ VERIFY_ARE_EQUAL(
233
+ std::wstring{L"Up 5 minutes"},
234
+ ContainerService::FormatStatus("Up 5 minutes", WslcContainerStateRunning, 0, models::FormatType::Table));
235
+ }
236
+
237
+ TEST_METHOD(FormatHealthStatus_Healthy_IsExtracted)
238
+ {
239
+ VERIFY_ARE_EQUAL(std::string{"healthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (healthy)"));
240
+ }
241
+
242
+ TEST_METHOD(FormatHealthStatus_Unhealthy_IsExtracted)
243
+ {
244
+ VERIFY_ARE_EQUAL(std::string{"unhealthy"}, ContainerService::FormatHealthStatus("Up 2 minutes (unhealthy)"));
245
+ }
246
+
247
+ TEST_METHOD(FormatHealthStatus_Starting_DropsHealthPrefix)
248
+ {
249
+ VERIFY_ARE_EQUAL(std::string{"starting"}, ContainerService::FormatHealthStatus("Up 2 seconds (health: starting)"));
250
+ }
251
+
252
+ TEST_METHOD(FormatHealthStatus_NoHealthCheck_IsEmpty)
253
+ {
254
+ VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes"));
255
+ VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus(""));
256
+ VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Created"));
257
+ }
258
+
259
+ TEST_METHOD(FormatHealthStatus_NoneIsNotReported)
260
+ {
261
+ VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (none)"));
262
+ }
263
+
264
+ TEST_METHOD(FormatHealthStatus_UnrelatedParentheses_AreIgnored)
265
+ {
266
+ VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Exited (0) 8 days ago"));
267
+ VERIFY_ARE_EQUAL(std::string{}, ContainerService::FormatHealthStatus("Up 2 minutes (Paused)"));
268
+ }
269
+};
270
+
271
+} // namespace WSLCCLIContainerCommandUnitTests
test/windows/wslc/WSLCCLIRelativeTimeUnitTests.cpp
+33
@@ -97,6 +97,39 @@ class WSLCCLIRelativeTimeUnitTests
97
VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeYears(2), FormatElapsed(730 * 24 * 60 * 60LL));
98
VERIFY_ARE_EQUAL(Localization::WSLCCLI_RelativeTimeYears(3), FormatElapsed(3 * 365 * 24 * 60 * 60LL));
99
}
100
+
101
+ // The invariant rendering backs machine readable output, so it must stay in English and match the
102
+ // strings docker produces through go-units HumanDuration.
103
+ TEST_METHOD(RelativeTime_Invariant)
104
+ {
105
+ VERIFY_ARE_EQUAL(std::wstring{}, FormatInvariantRelativeTime(0));
106
+ VERIFY_ARE_EQUAL(std::wstring{L"Less than a second ago"}, FormatInvariantElapsedSeconds(-600));
107
+ VERIFY_ARE_EQUAL(std::wstring{L"Less than a second ago"}, FormatInvariantElapsedSeconds(0));
108
+ VERIFY_ARE_EQUAL(std::wstring{L"1 second ago"}, FormatInvariantElapsedSeconds(1));
109
+ VERIFY_ARE_EQUAL(std::wstring{L"59 seconds ago"}, FormatInvariantElapsedSeconds(59));
110
+ VERIFY_ARE_EQUAL(std::wstring{L"About a minute ago"}, FormatInvariantElapsedSeconds(60));
111
+ VERIFY_ARE_EQUAL(std::wstring{L"2 minutes ago"}, FormatInvariantElapsedSeconds(120));
112
+ VERIFY_ARE_EQUAL(std::wstring{L"About an hour ago"}, FormatInvariantElapsedSeconds(89 * 60));
113
+ VERIFY_ARE_EQUAL(std::wstring{L"2 hours ago"}, FormatInvariantElapsedSeconds(90 * 60));
114
+ VERIFY_ARE_EQUAL(std::wstring{L"47 hours ago"}, FormatInvariantElapsedSeconds(47 * 60 * 60));
115
+ VERIFY_ARE_EQUAL(std::wstring{L"2 days ago"}, FormatInvariantElapsedSeconds(48 * 60 * 60));
116
+ VERIFY_ARE_EQUAL(std::wstring{L"2 weeks ago"}, FormatInvariantElapsedSeconds(14 * 24 * 60 * 60));
117
+ VERIFY_ARE_EQUAL(std::wstring{L"2 months ago"}, FormatInvariantElapsedSeconds(60 * 24 * 60 * 60));
118
+ VERIFY_ARE_EQUAL(std::wstring{L"2 years ago"}, FormatInvariantElapsedSeconds(730 * 24 * 60 * 60LL));
119
+ }
120
+
121
+ // Both renderings share one set of thresholds, so pin the invariant one at every boundary. This
122
+ // cannot compare against the localized rendering, which varies with the machine's display language.
123
+ TEST_METHOD(RelativeTime_InvariantBoundaries)
124
+ {
125
+ VERIFY_ARE_EQUAL(std::wstring{L"2 seconds ago"}, FormatInvariantElapsedSeconds(2));
126
+ VERIFY_ARE_EQUAL(std::wstring{L"About a minute ago"}, FormatInvariantElapsedSeconds(119));
127
+ VERIFY_ARE_EQUAL(std::wstring{L"59 minutes ago"}, FormatInvariantElapsedSeconds(59 * 60));
128
+ VERIFY_ARE_EQUAL(std::wstring{L"13 days ago"}, FormatInvariantElapsedSeconds(13 * 24 * 60 * 60));
129
+ VERIFY_ARE_EQUAL(std::wstring{L"8 weeks ago"}, FormatInvariantElapsedSeconds(59 * 24 * 60 * 60));
130
+ VERIFY_ARE_EQUAL(std::wstring{L"12 months ago"}, FormatInvariantElapsedSeconds(365 * 24 * 60 * 60));
131
+ VERIFY_ARE_EQUAL(std::wstring{L"3 years ago"}, FormatInvariantElapsedSeconds(3 * 365 * 24 * 60 * 60LL));
132
+ }
133
};
134
135
} // namespace WSLCCLIRelativeTimeUnitTests
test/windows/wslc/e2e/WSLCE2EContainerListTests.cpp
+115
-25
@@ -81,7 +81,7 @@ class WSLCE2EContainerListTests
81
82
// Verify we found the container in the list output
83
VERIFY_IS_TRUE(foundContainerLine.has_value());
84
- VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"created"));
84
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Created"));
85
}
86
87
WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_RunningContainers)
@@ -110,7 +110,7 @@ class WSLCE2EContainerListTests
110
111
// Verify we found the container in the list output
112
VERIFY_IS_TRUE(foundContainerLine.has_value());
113
- VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"running"));
113
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, foundContainerLine->find(L"Up "));
114
}
115
116
WSLC_TEST_METHOD(WSLCE2E_Container_List_NoOptions_ExcludesCreatedContainers)
@@ -139,6 +139,33 @@ class WSLCE2EContainerListTests
139
VERIFY_IS_FALSE(isListed);
140
}
141
142
+ // The table layout must match `docker container list` so users can rely on column order.
143
+ WSLC_TEST_METHOD(WSLCE2E_Container_List_TableFormat_MatchesDockerColumnOrder)
144
+ {
145
+ const auto result = RunWslc(L"container list --all");
146
+ result.Verify({.Stderr = L"", .ExitCode = 0});
147
+
148
+ const auto outputLines = result.GetStdoutLines();
149
+ VERIFY_IS_FALSE(outputLines.empty());
150
+
151
+ const auto& header = outputLines.front();
152
+ size_t position = 0;
153
+ for (const auto& column :
154
+ {Localization::WSLCCLI_TableHeaderContainerId(),
155
+ Localization::WSLCCLI_TableHeaderImage(),
156
+ Localization::WSLCCLI_TableHeaderCommand(),
157
+ Localization::WSLCCLI_TableHeaderCreated(),
158
+ Localization::WSLCCLI_TableHeaderStatus(),
159
+ Localization::WSLCCLI_TableHeaderPorts(),
160
+ Localization::WSLCCLI_TableHeaderNames()})
161
+ {
162
+ const auto found = header.find(column, position);
163
+ VERIFY_ARE_NOT_EQUAL(
164
+ std::wstring::npos, found, std::format(L"Column '{}' missing or out of order in '{}'", column, header).c_str());
165
+ position = found + column.size();
166
+ }
167
+ }
168
+
169
WSLC_TEST_METHOD(WSLCE2E_Container_List_QuietOption_OutputsIdsOnly)
170
{
171
VerifyContainerIsNotListed(WslcContainerName);
@@ -151,7 +178,12 @@ class WSLCE2EContainerListTests
178
result = RunWslc(L"container list --all --quiet");
179
result.Verify({.Stderr = L"", .ExitCode = 0});
180
154
- // Verify the created container ID appears in the quiet output.
181
+ const auto truncatedId = wsl::shared::string::MultiByteToWide(TruncateId(WideToMultiByte(containerId)));
182
+ VERIFY_ARE_EQUAL(12u, truncatedId.size());
183
+ VERIFY_IS_TRUE(result.StdoutContainsLine(truncatedId));
184
+
185
+ result = RunWslc(L"container list --all --quiet --no-trunc");
186
+ result.Verify({.Stderr = L"", .ExitCode = 0});
187
VERIFY_IS_TRUE(result.StdoutContainsLine(containerId));
188
}
189
@@ -174,15 +206,15 @@ class WSLCE2EContainerListTests
206
VERIFY_IS_FALSE(containerId.empty());
207
208
// List containers with json format
177
- result = RunWslc(L"container list --all --format json");
209
+ result = RunWslc(L"container list --all --format json --no-trunc");
210
result.Verify({.Stderr = L"", .ExitCode = 0});
211
// Parse json and verify we got the expected container information back
180
- auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
212
+ auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
213
VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 1U);
214
VERIFY_ARE_EQUAL(containers.size(), result.GetStdoutLines().size());
215
184
- auto findContainer = [](const std::vector<ContainerInformation>& list, const std::wstring& id) {
185
- return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.Id) == id; });
216
+ auto findContainer = [](const std::vector<ContainerOutputInformation>& list, const std::wstring& id) {
217
+ return std::ranges::any_of(list, [&](const auto& c) { return wsl::shared::string::MultiByteToWide(c.ID) == id; });
218
};
219
220
VERIFY_IS_TRUE(findContainer(containers, containerId));
@@ -194,16 +226,74 @@ class WSLCE2EContainerListTests
226
VERIFY_IS_FALSE(containerId2.empty());
227
228
// List containers with json format again
197
- result = RunWslc(L"container list --all --format json");
229
+ result = RunWslc(L"container list --all --format json --no-trunc");
230
result.Verify({.Stderr = L"", .ExitCode = 0});
231
// Parse json and verify we got both containers back
200
- containers = ParseNdjsonOutputAs<ContainerInformation>(result);
232
+ containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
233
VERIFY_IS_GREATER_THAN_OR_EQUAL(containers.size(), 2U);
234
235
VERIFY_IS_TRUE(findContainer(containers, containerId));
236
VERIFY_IS_TRUE(findContainer(containers, containerId2));
237
}
238
239
+ WSLC_TEST_METHOD(WSLCE2E_Container_List_JsonFormat_MatchesDockerShape)
240
+ {
241
+ const std::set<std::string> expectedKeys = {
242
+ "Command",
243
+ "CreatedAt",
244
+ "HealthStatus",
245
+ "ID",
246
+ "Image",
247
+ "Labels",
248
+ "LocalVolumes",
249
+ "Mounts",
250
+ "Names",
251
+ "Networks",
252
+ "Platform",
253
+ "Ports",
254
+ "RunningFor",
255
+ "Size",
256
+ "State",
257
+ "Status"};
258
+
259
+ VerifyContainerIsNotListed(WslcContainerName);
260
+
261
+ auto result = RunWslc(std::format(L"container create --name {} {}", WslcContainerName, DebianImage.NameAndTag()));
262
+ result.Verify({.Stderr = L"", .ExitCode = 0});
263
+
264
+ result = RunWslc(L"container list --all --format json");
265
+ result.Verify({.Stderr = L"", .ExitCode = 0});
266
+
267
+ const auto entries = ParseNdjsonOutput(result);
268
+ VERIFY_IS_GREATER_THAN_OR_EQUAL(entries.size(), 1u);
269
+
270
+ for (const auto& entry : entries)
271
+ {
272
+ std::set<std::string> keys;
273
+ for (const auto& [key, value] : entry.items())
274
+ {
275
+ keys.insert(key);
276
+
277
+ if (key == "Platform")
278
+ {
279
+ VERIFY_IS_TRUE(value.is_object());
280
+ }
281
+ else
282
+ {
283
+ VERIFY_IS_TRUE(
284
+ value.is_string(), wsl::shared::string::MultiByteToWide(std::format("'{}' must be a string", key)).c_str());
285
+ }
286
+ }
287
+
288
+ VERIFY_ARE_EQUAL(expectedKeys, keys, L"json output must contain exactly docker's container fields");
289
+
290
+ VERIFY_ARE_EQUAL(12u, entry["ID"].get<std::string>().size());
291
+ VERIFY_ARE_NOT_EQUAL(std::string{}, entry["Names"].get<std::string>());
292
+ VERIFY_ARE_NOT_EQUAL(std::string{}, entry["State"].get<std::string>());
293
+ VERIFY_ARE_EQUAL(std::string{"linux"}, entry["Platform"]["os"].get<std::string>());
294
+ }
295
+ }
296
+
297
WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_InvalidKey)
298
{
299
// Filter keys are validated by the Docker daemon, which rejects unknown keys.
@@ -246,9 +336,9 @@ class WSLCE2EContainerListTests
336
result = RunWslc(std::format(L"container list --all --format json --filter name={}", WslcContainerName2));
337
result.Verify({.Stderr = L"", .ExitCode = 0});
338
249
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
339
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
340
VERIFY_ARE_EQUAL(1U, containers.size());
251
- VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name));
341
+ VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names));
342
}
343
344
WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Status)
@@ -267,11 +357,11 @@ class WSLCE2EContainerListTests
357
auto listNames = [&](const std::wstring& filterArgs) {
358
auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
359
r.Verify({.Stderr = L"", .ExitCode = 0});
270
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
360
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
361
std::set<std::string> names;
362
for (const auto& c : containers)
363
{
274
- names.insert(c.Name);
364
+ names.insert(c.Names);
365
}
366
return names;
367
};
@@ -314,11 +404,11 @@ class WSLCE2EContainerListTests
404
auto listNames = [&](const std::wstring& filterArgs) {
405
auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
406
r.Verify({.Stderr = L"", .ExitCode = 0});
317
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
407
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
408
std::set<std::string> names;
409
for (const auto& c : containers)
410
{
321
- names.insert(c.Name);
411
+ names.insert(c.Names);
412
}
413
return names;
414
};
@@ -358,12 +448,12 @@ class WSLCE2EContainerListTests
448
result.Verify({.Stderr = L"", .ExitCode = 0});
449
450
// Filter by id (full id) should return exactly one container.
361
- result = RunWslc(std::format(L"container list --all --format json --filter id={}", containerId));
451
+ result = RunWslc(std::format(L"container list --all --format json --no-trunc --filter id={}", containerId));
452
result.Verify({.Stderr = L"", .ExitCode = 0});
453
364
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
454
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
455
VERIFY_ARE_EQUAL(1U, containers.size());
366
- VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].Id));
456
+ VERIFY_ARE_EQUAL(WideToMultiByte(containerId), std::string(containers[0].ID));
457
}
458
459
WSLC_TEST_METHOD(WSLCE2E_Container_List_Filter_Exited)
@@ -382,11 +472,11 @@ class WSLCE2EContainerListTests
472
auto listNames = [&](const std::wstring& filterArgs) {
473
auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
474
r.Verify({.Stderr = L"", .ExitCode = 0});
385
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
475
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
476
std::set<std::string> names;
477
for (const auto& c : containers)
478
{
389
- names.insert(c.Name);
479
+ names.insert(c.Names);
480
}
481
return names;
482
};
@@ -421,11 +511,11 @@ class WSLCE2EContainerListTests
511
auto listNames = [&](const std::wstring& filterArgs) {
512
auto r = RunWslc(std::format(L"container list --all --format json {}", filterArgs));
513
r.Verify({.Stderr = L"", .ExitCode = 0});
424
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(r);
514
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(r);
515
std::set<std::string> names;
516
for (const auto& c : containers)
517
{
428
- names.insert(c.Name);
518
+ names.insert(c.Names);
519
}
520
return names;
521
};
@@ -463,9 +553,9 @@ class WSLCE2EContainerListTests
553
result = RunWslc(L"container list --latest --format json");
554
result.Verify({.Stderr = L"", .ExitCode = 0});
555
466
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
556
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
557
VERIFY_ARE_EQUAL(1U, containers.size());
468
- VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Name));
558
+ VERIFY_ARE_EQUAL(WideToMultiByte(WslcContainerName2), std::string(containers[0].Names));
559
}
560
561
// --last 2 should cap output at 2 containers.
@@ -473,7 +563,7 @@ class WSLCE2EContainerListTests
563
result = RunWslc(L"container list --last 2 --format json");
564
result.Verify({.Stderr = L"", .ExitCode = 0});
565
476
- const auto containers = ParseNdjsonOutputAs<ContainerInformation>(result);
566
+ const auto containers = ParseNdjsonOutputAs<ContainerOutputInformation>(result);
567
VERIFY_IS_TRUE(containers.size() <= 2u);
568
}
569
test/windows/wslc/e2e/WSLCE2EHelpers.cpp
+25
-8
@@ -152,6 +152,22 @@ TestSession::~TestSession()
152
153
void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::wstring& status, const std::wstring& sessionName)
154
{
155
+ // The status column reports the runtime's description, e.g. "Up 5 seconds", so map the logical
156
+ // state callers pass in onto the text that description starts with.
157
+ std::wstring expectedStatus = status;
158
+ if (status == L"created")
159
+ {
160
+ expectedStatus = L"Created";
161
+ }
162
+ else if (status == L"running")
163
+ {
164
+ expectedStatus = L"Up ";
165
+ }
166
+ else if (status == L"exited")
167
+ {
168
+ expectedStatus = L"Exited (";
169
+ }
170
+
171
std::wstring command = L"container list --no-trunc --all";
172
if (!sessionName.empty())
173
{
@@ -167,8 +183,8 @@ void VerifyContainerIsListed(const std::wstring& containerNameOrId, const std::w
183
if (line.find(containerNameOrId) != std::wstring::npos)
184
{
185
const std::wstring message = L"Container '" + containerNameOrId + L"' found in container list output but status '" +
170
- status + L"' was not found in the same line";
171
- VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(status), message.c_str());
186
+ expectedStatus + L"' was not found in the same line";
187
+ VERIFY_ARE_NOT_EQUAL(std::wstring::npos, line.find(expectedStatus), message.c_str());
188
return;
189
}
190
}
@@ -348,7 +364,7 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName)
364
{
365
const auto name = wsl::shared::string::WideToMultiByte(containerName);
366
const auto containers = ListAllContainers();
351
- auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Name == name; });
367
+ auto it = std::ranges::find_if(containers, [&](const auto& c) { return c.Names == name; });
368
if (it == containers.end())
369
{
370
return;
@@ -362,11 +378,12 @@ void EnsureContainerDoesNotExist(const std::wstring& containerName)
378
}
379
}
380
365
-std::vector<wsl::windows::wslc::models::ContainerInformation> ListAllContainers()
381
+std::vector<wsl::windows::wslc::models::ContainerOutputInformation> ListAllContainers()
382
{
367
- auto result = RunWslc(L"container list --all --format json");
383
+ // --no-trunc keeps the full ids, which callers use to address containers.
384
+ auto result = RunWslc(L"container list --all --format json --no-trunc");
385
result.Verify({.Stderr = L"", .ExitCode = 0});
369
- return ParseNdjsonOutputAs<wsl::windows::wslc::models::ContainerInformation>(result);
386
+ return ParseNdjsonOutputAs<wsl::windows::wslc::models::ContainerOutputInformation>(result);
387
}
388
389
void EnsureImageContainersAreDeleted(const TestImage& image)
@@ -377,8 +394,8 @@ void EnsureImageContainersAreDeleted(const TestImage& image)
394
auto nameAndTag = wsl::shared::string::WideToMultiByte(image.NameAndTag());
395
if (container.Image.find(nameAndTag) != std::string::npos)
396
{
380
- auto result = RunWslc(std::format(L"container remove --force {}", container.Id));
381
- result.Verify({.Stdout = std::format(L"{}\r\n", container.Id), .Stderr = L"", .ExitCode = 0});
397
+ auto result = RunWslc(std::format(L"container remove --force {}", container.ID));
398
+ result.Verify({.Stdout = std::format(L"{}\r\n", container.ID), .Stderr = L"", .ExitCode = 0});
399
}
400
}
401
}
test/windows/wslc/e2e/WSLCE2EHelpers.h
+1
-1
@@ -155,7 +155,7 @@ wsl::windows::common::wslc_schema::InspectContainer InspectContainer(const std::
155
wsl::windows::common::wslc_schema::InspectImage InspectImage(const std::wstring& imageName);
156
wsl::windows::common::wslc_schema::InspectVolume InspectVolume(const std::wstring& volumeName);
157
wsl::windows::common::wslc_schema::Network InspectNetwork(const std::wstring& networkName);
158
-std::vector<wsl::windows::wslc::models::ContainerInformation> ListAllContainers();
158
+std::vector<wsl::windows::wslc::models::ContainerOutputInformation> ListAllContainers();
159
160
void EnsureContainerDoesNotExist(const std::wstring& containerName);
161
void DeleteImagesWithRepositoryPrefix(const std::wstring& repositoryPrefix);