| 1 | /*++ |
| 2 | |
| 3 | Copyright (c) Microsoft. All rights reserved. |
| 4 | |
| 5 | Module Name: |
| 6 | |
| 7 | WSLCCLIOutputParserUnitTests.cpp |
| 8 | |
| 9 | Abstract: |
| 10 | |
| 11 | This file contains unit tests for WSLC CLI --output spec validation and parsing (validation::ParseOutputSpec). |
| 12 | |
| 13 | These tests define the contract for the docker-style `wslc image build --output` flag, mirroring |
| 14 | `docker buildx build --output`. The parser under test is expected to expose: |
| 15 | |
| 16 | namespace wsl::windows::wslc::services { |
| 17 | struct BuildOutput |
| 18 | { |
| 19 | std::wstring Type; // resolved exporter type (e.g. L"local", L"tar", ...) |
| 20 | std::wstring Dest; // destination path; L"-" means stdout; empty when not applicable |
| 21 | std::map<std::wstring, std::wstring> Attributes; // remaining key=value attributes (name, push, compression, ...) |
| 22 | }; |
| 23 | } |
| 24 | |
| 25 | namespace wsl::windows::wslc::validation { |
| 26 | services::BuildOutput ParseOutputSpec(const std::wstring& spec); |
| 27 | } |
| 28 | |
| 29 | Grammar / behavior (docker buildx parity): |
| 30 | * The spec is parsed as a single CSV record (RFC 4180, as buildx does via go-csvvalue): fields |
| 31 | are comma separated, a field may be double-quoted, "" inside a quoted field is a literal quote, |
| 32 | and a comma inside a quoted field is part of the value. |
| 33 | * A single field that equals the whole input and does not start with "type=" is shorthand for the |
| 34 | destination: |
| 35 | - L"-" -> {type=tar, dest=-} (stream a tarball to stdout, matching docker) |
| 36 | - any other path -> {type=local, dest=<path>} (rejected: directory exporters are unsupported) |
| 37 | * Otherwise each field is split on its FIRST '=' into key/value (two parts required). The key is |
| 38 | trimmed and lowercased; the value is kept verbatim (may itself contain '='). 'type' and 'dest' |
| 39 | populate the struct fields; every other key is stored in Attributes. |
| 40 | * Validation / destination resolution: |
| 41 | - 'type' is required and must be one of: local, tar, oci, docker, image, registry, cacheonly. |
| 42 | - Directory exporters (local, or oci/docker with tar=false) are not supported and are rejected. |
| 43 | - tar / oci with no 'dest=' default to streaming a tarball to stdout ('dest=-'), matching buildx. |
| 44 | - docker with no 'dest=' loads the image into the store; 'dest=-' streams a tarball to stdout; |
| 45 | a path writes a file. |
| 46 | - image / registry / cacheonly run in the build VM and ignore 'dest'; 'name=' is optional |
| 47 | (buildx only enforces it at export time, not at parse time). |
| 48 | * On rejection the parser throws ArgumentException whose message is the standard |
| 49 | "Invalid --output value '<spec>': <reason>" wrapper (Localization::MessageWslcOutputInvalidSpec). |
| 50 | |
| 51 | --*/ |
| 52 | |
| 53 | #include "precomp.h" |
| 54 | #include "windows/Common.h" |
| 55 | #include "WSLCCLITestHelpers.h" |
| 56 | #include "ArgumentValidation.h" |
| 57 | #include "ImageService.h" |
| 58 | #include "Exceptions.h" |
| 59 | #include <map> |
| 60 | #include <string> |
| 61 | |
| 62 | using namespace wsl::windows::wslc; |
| 63 | using namespace WEX::Logging; |
| 64 | using namespace WEX::Common; |
| 65 | |
| 66 | namespace WSLCCLIOutputParserUnitTests { |
| 67 | |
| 68 | using AttrMap = std::map<std::wstring, std::wstring>; |
| 69 | |
| 70 | class WSLCCLIOutputParserUnitTests |
| 71 | { |
| 72 | WSLC_TEST_CLASS(WSLCCLIOutputParserUnitTests) |
| 73 | |
| 74 | // Parses a spec expected to be valid and asserts the resolved type, destination and attributes. |
| 75 | static void VerifyValid(const std::wstring& spec, const std::wstring& expectedType, const std::wstring& expectedDest, const AttrMap& expectedAttrs = {}) |
| 76 | { |
| 77 | auto output = validation::ParseOutputSpec(spec); |
| 78 | VERIFY_ARE_EQUAL(expectedType, output.Type); |
| 79 | VERIFY_ARE_EQUAL(expectedDest, output.Dest); |
| 80 | VERIFY_ARE_EQUAL(expectedAttrs.size(), output.Attributes.size()); |
| 81 | for (const auto& [key, value] : expectedAttrs) |
| 82 | { |
| 83 | const auto it = output.Attributes.find(key); |
| 84 | VERIFY_IS_TRUE(it != output.Attributes.end()); |
| 85 | if (it != output.Attributes.end()) |
| 86 | { |
| 87 | VERIFY_ARE_EQUAL(value, it->second); |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // Parses a spec expected to be rejected and asserts it throws an ArgumentException whose message is |
| 93 | // the standard "Invalid --output value '<spec>': <reason>" wrapper and contains the expected reason. |
| 94 | static void VerifyInvalid(const std::wstring& spec, const std::wstring& expectedReasonSubstr) |
| 95 | { |
| 96 | Log::Comment(String().Format(L"Rejecting: %ls", spec.c_str())); |
| 97 | try |
| 98 | { |
| 99 | (void)validation::ParseOutputSpec(spec); |
| 100 | VERIFY_FAIL(L"Expected ArgumentException for invalid output spec"); |
| 101 | } |
| 102 | catch (const ArgumentException& ex) |
| 103 | { |
| 104 | const std::wstring& message = ex.Message(); |
| 105 | VERIFY_IS_TRUE(message.find(L"Invalid --output value") != std::wstring::npos); |
| 106 | VERIFY_IS_TRUE(message.find(expectedReasonSubstr) != std::wstring::npos); |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // --- Valid: shorthand (single token, no key=value pairs) --- |
| 111 | |
| 112 | TEST_METHOD(Output_Shorthand_DashIsTarToStdout) |
| 113 | { |
| 114 | // '-' is docker's shorthand for streaming a tarball to stdout ('type=tar,dest=-'). |
| 115 | VerifyValid(L"-", L"tar", L"-"); |
| 116 | } |
| 117 | |
| 118 | // --- Invalid: directory exporters are not supported --- |
| 119 | |
| 120 | TEST_METHOD(Output_LocalExporter_Rejected) |
| 121 | { |
| 122 | // The local exporter - a bare-path shorthand or an explicit type=local - writes a Linux |
| 123 | // directory tree, which is not supported, so every form is rejected regardless of destination. |
| 124 | // 'dest=./out' is buildx's single-field shorthand quirk: a lone field containing '=' that does |
| 125 | // not start with 'type=' still names a local path. |
| 126 | for (const auto* spec : |
| 127 | {L"./out", L"C:\\build\\artifacts", L"dest=./out", L"type=local", L"type=local,dest=./out", L"type=local,dest=-"}) |
| 128 | { |
| 129 | VerifyInvalid(spec, L"directory exporters are not supported"); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | TEST_METHOD(Output_OciDockerTarFalse_Rejected) |
| 134 | { |
| 135 | // oci/docker export an OCI layout directory when 'tar' is false; buildx parses 'tar' with Go's |
| 136 | // ParseBool, so every false spelling (false/False/0/f) is a directory exporter and is rejected |
| 137 | // the same way, with or without a destination. |
| 138 | for (const auto* spec : |
| 139 | {L"type=oci,dest=./layout,tar=false", |
| 140 | L"type=oci,tar=false", |
| 141 | L"type=oci,dest=./layout,tar=False", |
| 142 | L"type=oci,dest=./layout,tar=0", |
| 143 | L"type=oci,dest=./layout,tar=f", |
| 144 | L"type=docker,dest=./layout,tar=false", |
| 145 | L"type=docker,dest=./layout,tar=0"}) |
| 146 | { |
| 147 | VerifyInvalid(spec, L"directory exporters are not supported"); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // --- Valid: explicit tar / oci / docker exporters --- |
| 152 | |
| 153 | TEST_METHOD(Output_Tar_ToFile) |
| 154 | { |
| 155 | VerifyValid(L"type=tar,dest=out.tar", L"tar", L"out.tar"); |
| 156 | } |
| 157 | |
| 158 | TEST_METHOD(Output_Tar_ToStdout) |
| 159 | { |
| 160 | // tar streams a single tarball, so it may target stdout ('dest=-'), matching docker. |
| 161 | VerifyValid(L"type=tar,dest=-", L"tar", L"-"); |
| 162 | } |
| 163 | |
| 164 | TEST_METHOD(Output_Tar_NoDest_DefaultsToStdout) |
| 165 | { |
| 166 | // tar with no destination streams a tarball to stdout ('dest=-'), matching buildx. |
| 167 | VerifyValid(L"type=tar", L"tar", L"-"); |
| 168 | } |
| 169 | |
| 170 | TEST_METHOD(Output_Oci_ToFile) |
| 171 | { |
| 172 | VerifyValid(L"type=oci,dest=image.tar", L"oci", L"image.tar"); |
| 173 | } |
| 174 | |
| 175 | TEST_METHOD(Output_Oci_NoDest_DefaultsToStdout) |
| 176 | { |
| 177 | // oci with no destination streams a tarball to stdout ('dest=-'), matching buildx. |
| 178 | VerifyValid(L"type=oci", L"oci", L"-"); |
| 179 | } |
| 180 | |
| 181 | // --- Valid: 'tar' true spellings keep oci/docker a single tarball (buildx parity) --- |
| 182 | |
| 183 | TEST_METHOD(Output_Oci_TarTrue_IsSingleTarballToStdout) |
| 184 | { |
| 185 | // "True" is true for Go's ParseBool, so oci stays a single tarball and defaults to stdout. |
| 186 | VerifyValid(L"type=oci,tar=True", L"oci", L"-", AttrMap{{L"tar", L"True"}}); |
| 187 | } |
| 188 | |
| 189 | TEST_METHOD(Output_Oci_TarOne_IsSingleTarballToFile) |
| 190 | { |
| 191 | // "1" is true, so this is a single-tarball export to a file (not a directory). |
| 192 | VerifyValid(L"type=oci,dest=image.tar,tar=1", L"oci", L"image.tar", AttrMap{{L"tar", L"1"}}); |
| 193 | } |
| 194 | |
| 195 | TEST_METHOD(Output_Docker_TarTrue_LoadsIntoStore) |
| 196 | { |
| 197 | // docker with tar=true is a single tarball; with no dest it loads into the VM store (dest empty). |
| 198 | VerifyValid(L"type=docker,tar=t", L"docker", L"", AttrMap{{L"tar", L"t"}}); |
| 199 | } |
| 200 | |
| 201 | TEST_METHOD(Output_Oci_TarInvalidBool_Rejected) |
| 202 | { |
| 203 | // A non-boolean 'tar' value is rejected up front, matching buildx (which errors in ParseBool). |
| 204 | VerifyInvalid(L"type=oci,dest=./layout,tar=yes", L"invalid boolean value 'yes' for 'tar'"); |
| 205 | } |
| 206 | |
| 207 | TEST_METHOD(Output_Docker_TarInvalidBool_Rejected) |
| 208 | { |
| 209 | VerifyInvalid(L"type=docker,dest=./layout,tar=maybe", L"invalid boolean value 'maybe' for 'tar'"); |
| 210 | } |
| 211 | |
| 212 | TEST_METHOD(Output_Docker_ToFile) |
| 213 | { |
| 214 | VerifyValid(L"type=docker,dest=image.tar", L"docker", L"image.tar"); |
| 215 | } |
| 216 | |
| 217 | TEST_METHOD(Output_Docker_ToStdout) |
| 218 | { |
| 219 | // docker with dest=- streams the image tarball to stdout (matching docker), which the client |
| 220 | // routes to the redirected stdout handle. |
| 221 | VerifyValid(L"type=docker,dest=-", L"docker", L"-"); |
| 222 | } |
| 223 | |
| 224 | TEST_METHOD(Output_Docker_NoDestLoadsIntoStore) |
| 225 | { |
| 226 | // The docker exporter loads the image into the local store when no destination is given. |
| 227 | VerifyValid(L"type=docker", L"docker", L""); |
| 228 | } |
| 229 | |
| 230 | TEST_METHOD(Output_CacheOnly) |
| 231 | { |
| 232 | // cacheonly runs the build without exporting an artifact. |
| 233 | VerifyValid(L"type=cacheonly", L"cacheonly", L""); |
| 234 | } |
| 235 | |
| 236 | // --- Valid: image / registry exporters with attributes --- |
| 237 | |
| 238 | TEST_METHOD(Output_Image_NameAndPush) |
| 239 | { |
| 240 | VerifyValid( |
| 241 | L"type=image,name=myrepo/app:1.0,push=true", L"image", L"", AttrMap{{L"name", L"myrepo/app:1.0"}, {L"push", L"true"}}); |
| 242 | } |
| 243 | |
| 244 | TEST_METHOD(Output_Registry_Name) |
| 245 | { |
| 246 | VerifyValid(L"type=registry,name=myrepo/app:latest", L"registry", L"", AttrMap{{L"name", L"myrepo/app:latest"}}); |
| 247 | } |
| 248 | |
| 249 | TEST_METHOD(Output_Registry_NoName_Valid) |
| 250 | { |
| 251 | // buildx only enforces 'name=' at export time, not at parse time, so parsing must accept it. |
| 252 | VerifyValid(L"type=registry", L"registry", L""); |
| 253 | } |
| 254 | |
| 255 | TEST_METHOD(Output_Registry_PushAttributes) |
| 256 | { |
| 257 | // Registry/push related attributes are passed through verbatim. |
| 258 | VerifyValid( |
| 259 | L"type=registry,name=myrepo/app:latest,push-by-digest=true,insecure=true,dangling-name-prefix=cache", |
| 260 | L"registry", |
| 261 | L"", |
| 262 | AttrMap{ |
| 263 | {L"name", L"myrepo/app:latest"}, |
| 264 | {L"push-by-digest", L"true"}, |
| 265 | {L"insecure", L"true"}, |
| 266 | {L"dangling-name-prefix", L"cache"}}); |
| 267 | } |
| 268 | |
| 269 | TEST_METHOD(Output_Image_StoreAttributes) |
| 270 | { |
| 271 | // Image-store related attributes are passed through verbatim. |
| 272 | VerifyValid( |
| 273 | L"type=image,name=x,store=true,unpack=true,name-canonical=true", |
| 274 | L"image", |
| 275 | L"", |
| 276 | AttrMap{{L"name", L"x"}, {L"store", L"true"}, {L"unpack", L"true"}, {L"name-canonical", L"true"}}); |
| 277 | } |
| 278 | |
| 279 | // --- Valid: attribute passthrough --- |
| 280 | |
| 281 | TEST_METHOD(Output_Attributes_CompressionOptions) |
| 282 | { |
| 283 | VerifyValid( |
| 284 | L"type=image,name=x,compression=zstd,compression-level=19,oci-mediatypes=true", |
| 285 | L"image", |
| 286 | L"", |
| 287 | AttrMap{{L"name", L"x"}, {L"compression", L"zstd"}, {L"compression-level", L"19"}, {L"oci-mediatypes", L"true"}}); |
| 288 | } |
| 289 | |
| 290 | TEST_METHOD(Output_Attributes_ForceCompression) |
| 291 | { |
| 292 | VerifyValid( |
| 293 | L"type=oci,dest=o.tar,compression=gzip,compression-level=5,force-compression=true", |
| 294 | L"oci", |
| 295 | L"o.tar", |
| 296 | AttrMap{{L"compression", L"gzip"}, {L"compression-level", L"5"}, {L"force-compression", L"true"}}); |
| 297 | } |
| 298 | |
| 299 | TEST_METHOD(Output_Attributes_ScopedAnnotation) |
| 300 | { |
| 301 | // Scoped annotations (annotation-manifest./annotation-index.) are preserved as-is. |
| 302 | VerifyValid( |
| 303 | L"type=oci,dest=o.tar,annotation-manifest.org.opencontainers.image.title=app", |
| 304 | L"oci", |
| 305 | L"o.tar", |
| 306 | AttrMap{{L"annotation-manifest.org.opencontainers.image.title", L"app"}}); |
| 307 | } |
| 308 | |
| 309 | TEST_METHOD(Output_Tar_PlatformSplit) |
| 310 | { |
| 311 | // platform-split is forwarded verbatim as an exporter attribute for the tar exporter. |
| 312 | VerifyValid(L"type=tar,dest=out.tar,platform-split=false", L"tar", L"out.tar", AttrMap{{L"platform-split", L"false"}}); |
| 313 | } |
| 314 | |
| 315 | TEST_METHOD(Output_Attributes_AnnotationValueMayContainEquals) |
| 316 | { |
| 317 | // Only the first '=' separates key from value, so annotation values may themselves contain '='. |
| 318 | VerifyValid( |
| 319 | L"type=oci,dest=o.tar,annotation.org.opencontainers.image.source=https://example.com/repo?ref=main", |
| 320 | L"oci", |
| 321 | L"o.tar", |
| 322 | AttrMap{{L"annotation.org.opencontainers.image.source", L"https://example.com/repo?ref=main"}}); |
| 323 | } |
| 324 | |
| 325 | TEST_METHOD(Output_Attributes_EmptyValuePreserved) |
| 326 | { |
| 327 | // A key with an explicit but empty value is preserved (the separator was present). |
| 328 | VerifyValid(L"type=image,name=x,push=", L"image", L"", AttrMap{{L"name", L"x"}, {L"push", L""}}); |
| 329 | } |
| 330 | |
| 331 | TEST_METHOD(Output_Keys_AreCaseInsensitive) |
| 332 | { |
| 333 | VerifyValid(L"TYPE=tar,DEST=out.tar", L"tar", L"out.tar"); |
| 334 | } |
| 335 | |
| 336 | // --- Invalid: spec structure --- |
| 337 | |
| 338 | TEST_METHOD(Output_Invalid_Empty) |
| 339 | { |
| 340 | VerifyInvalid(L"", L"may not be empty"); |
| 341 | } |
| 342 | |
| 343 | TEST_METHOD(Output_Invalid_FieldWithoutEquals) |
| 344 | { |
| 345 | VerifyInvalid(L"type=local,garbage", L"expected key=value pairs separated by ','"); |
| 346 | } |
| 347 | |
| 348 | TEST_METHOD(Output_Invalid_LeadingFieldWithoutEquals) |
| 349 | { |
| 350 | VerifyInvalid(L"garbage,type=local", L"expected key=value pairs separated by ','"); |
| 351 | } |
| 352 | |
| 353 | TEST_METHOD(Output_Invalid_EmptyField) |
| 354 | { |
| 355 | VerifyInvalid(L"type=local,,dest=x", L"expected key=value pairs separated by ','"); |
| 356 | } |
| 357 | |
| 358 | // --- Invalid: type constraints --- |
| 359 | |
| 360 | TEST_METHOD(Output_Invalid_EmptyTypeValue) |
| 361 | { |
| 362 | VerifyInvalid(L"type=,dest=x", L"type is required"); |
| 363 | } |
| 364 | |
| 365 | TEST_METHOD(Output_Invalid_MissingType) |
| 366 | { |
| 367 | // With two or more fields no shorthand applies, so a spec without 'type=' is rejected. |
| 368 | VerifyInvalid(L"dest=./out,compression=gzip", L"type is required"); |
| 369 | } |
| 370 | |
| 371 | TEST_METHOD(Output_Invalid_UnsupportedType) |
| 372 | { |
| 373 | VerifyInvalid(L"type=bogus", L"unsupported output type 'bogus'"); |
| 374 | } |
| 375 | |
| 376 | // --- CSV grammar (buildx go-csvvalue parity) --- |
| 377 | |
| 378 | TEST_METHOD(Output_Csv_QuotedValueWithComma) |
| 379 | { |
| 380 | // A comma inside a double-quoted field is part of the value, not a field separator. |
| 381 | VerifyValid( |
| 382 | L"type=image,name=x,\"annotation.foo=a,b,c\"", L"image", L"", AttrMap{{L"name", L"x"}, {L"annotation.foo", L"a,b,c"}}); |
| 383 | } |
| 384 | |
| 385 | TEST_METHOD(Output_Csv_QuotedValueWithEscapedQuote) |
| 386 | { |
| 387 | // A doubled quote inside a quoted field is a single literal quote. |
| 388 | VerifyValid( |
| 389 | L"type=image,name=x,\"annotation.foo=a\"\"b\"", L"image", L"", AttrMap{{L"name", L"x"}, {L"annotation.foo", L"a\"b"}}); |
| 390 | } |
| 391 | |
| 392 | TEST_METHOD(Output_Csv_LeadingSpaceAfterCommaTrimmedFromKey) |
| 393 | { |
| 394 | // buildx TrimSpace's the key, so a space after a comma is accepted (the value is untrimmed). |
| 395 | VerifyValid(L"type=tar, dest=out.tar", L"tar", L"out.tar"); |
| 396 | } |
| 397 | |
| 398 | TEST_METHOD(Output_Csv_UnterminatedQuoteRejected) |
| 399 | { |
| 400 | VerifyInvalid(L"type=image,\"name=x", L"malformed quoting"); |
| 401 | } |
| 402 | |
| 403 | // --- Round-trip: FormatOutputSpec re-serializes a BuildOutput into a canonical buildx spec --- |
| 404 | |
| 405 | // Parses spec, formats the result, and asserts the canonical serialized form. |
| 406 | static void VerifyFormat(const std::wstring& spec, const std::wstring& expectedCanonical) |
| 407 | { |
| 408 | const auto canonical = validation::FormatOutputSpec(validation::ParseOutputSpec(spec)); |
| 409 | VERIFY_ARE_EQUAL(expectedCanonical, canonical); |
| 410 | |
| 411 | // The canonical form must itself parse back to an equivalent BuildOutput (idempotent round-trip). |
| 412 | const auto reparsed = validation::ParseOutputSpec(canonical); |
| 413 | const auto original = validation::ParseOutputSpec(spec); |
| 414 | VERIFY_ARE_EQUAL(original.Type, reparsed.Type); |
| 415 | VERIFY_ARE_EQUAL(original.Dest, reparsed.Dest); |
| 416 | VERIFY_ARE_EQUAL(original.Attributes.size(), reparsed.Attributes.size()); |
| 417 | for (const auto& [key, value] : original.Attributes) |
| 418 | { |
| 419 | const auto it = reparsed.Attributes.find(key); |
| 420 | VERIFY_IS_TRUE(it != reparsed.Attributes.end()); |
| 421 | if (it != reparsed.Attributes.end()) |
| 422 | { |
| 423 | VERIFY_ARE_EQUAL(value, it->second); |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | TEST_METHOD(Format_TypeOnly_NoDestOrAttributes) |
| 429 | { |
| 430 | // docker/cacheonly need neither dest nor attributes, so the canonical form is just the type. |
| 431 | VerifyFormat(L"type=docker", L"type=docker"); |
| 432 | VerifyFormat(L"type=cacheonly", L"type=cacheonly"); |
| 433 | } |
| 434 | |
| 435 | TEST_METHOD(Format_TypeAndDest) |
| 436 | { |
| 437 | VerifyFormat(L"type=tar,dest=out.tar", L"type=tar,dest=out.tar"); |
| 438 | } |
| 439 | |
| 440 | TEST_METHOD(Format_CaseInsensitiveKeysNormalizedToLower) |
| 441 | { |
| 442 | // 'type'/'dest' keys are lowercased; the type value is lowercased too. |
| 443 | VerifyFormat(L"TYPE=TAR,DEST=out.tar", L"type=tar,dest=out.tar"); |
| 444 | } |
| 445 | |
| 446 | TEST_METHOD(Format_AttributesAppendedAfterDest) |
| 447 | { |
| 448 | // Attributes follow type/dest; std::map orders them, so 'name' precedes 'push'. |
| 449 | VerifyFormat(L"type=image,push=true,name=x", L"type=image,name=x,push=true"); |
| 450 | } |
| 451 | |
| 452 | TEST_METHOD(Format_RegistryWithAttributes) |
| 453 | { |
| 454 | VerifyFormat( |
| 455 | L"type=registry,name=myrepo/app:latest,push-by-digest=true", |
| 456 | L"type=registry,name=myrepo/app:latest,push-by-digest=true"); |
| 457 | } |
| 458 | |
| 459 | TEST_METHOD(Format_QuotesValueContainingComma) |
| 460 | { |
| 461 | // An attribute value containing a comma is CSV-quoted so it round-trips through the parser. |
| 462 | // std::map orders attributes, so 'annotation.foo' precedes 'name'. |
| 463 | VerifyFormat(L"type=image,name=x,\"annotation.foo=a,b,c\"", L"type=image,\"annotation.foo=a,b,c\",name=x"); |
| 464 | } |
| 465 | |
| 466 | TEST_METHOD(Format_TarNoDestDefaultsToStdout) |
| 467 | { |
| 468 | // tar with no dest resolves to dest=- and serializes back to that canonical form. |
| 469 | VerifyFormat(L"type=tar", L"type=tar,dest=-"); |
| 470 | } |
| 471 | }; |
| 472 | |
| 473 | } // namespace WSLCCLIOutputParserUnitTests |