Skip to content

Commit 92c76b2

Browse files
azchohfiCopilot
andcommitted
Write JSON payloads straight to stdout instead of through Spectre
`submission get` and every other command that emits a machine-readable payload wrote it via Spectre.Console's static `AnsiConsole.WriteLine`. That renders the string through Spectre's layout pipeline, which word-wraps at `Profile.Width`. The wrap is unaware of JSON syntax, so it injected raw U+000A/U+000D characters inside string values and could split a `\uXXXX` escape in half. The result was invalid JSON (RFC 8259 §7 forbids unescaped control characters in strings), and — worse — lenient parsers such as PowerShell's `ConvertFrom-Json` accepted it and silently corrupted the listing text, so the documented `submission get` -> edit -> `submission update` round-trip could republish mangled store copy. Redirected stdout was the worst case, since Spectre falls back to 80 columns when there is no console. Program.cs already puts all human-facing output on stderr and reserves stdout for the payload; only the renderer was wrong. Add a `StandardOutput.WriteLine` helper that writes to `Console.Out` directly and route the 19 JSON call sites through it. `package` writes its output directory to stdout for the same reason, so it moves too — that also fixes a latent Spectre markup parse error for paths containing `[`. The path is no longer green/bold. Also fix a latent test-harness bug this uncovered: `OutputCapture` called `Console.SetOut(this)` from its constructor, so the error capture silently hijacked `Console.Out`. It is now set explicitly for stdout only, matching how Program.cs keeps the two streams apart. Fixes #150 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: af8e5c71-6935-4efa-b777-64b180bb20c7
1 parent 99b6b6b commit 92c76b2

24 files changed

Lines changed: 139 additions & 31 deletions

MSStore.CLI.UnitTests/BaseCommandLineTest.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ internal void AddFakeAccount(AccountEnrollment? accountEnrollment)
372372
});
373373
}
374374

375-
protected void AddDefaultFakeSubmission()
375+
protected void AddDefaultFakeSubmission(string listingDescription = "BaseListingDescription")
376376
{
377377
var fakeSubmission = new DevCenterSubmission
378378
{
@@ -406,7 +406,7 @@ protected void AddDefaultFakeSubmission()
406406
{
407407
BaseListing = new BaseListing
408408
{
409-
Description = "BaseListingDescription"
409+
Description = listingDescription
410410
}
411411
}
412412
}
@@ -764,6 +764,10 @@ protected void SetupBasedOnTestDataProjectSubPath(DirectoryInfo dirInfo, string[
764764
var outputCapture = new OutputCapture(Console.Out);
765765
var errorCapture = RefreshAnsiConsole();
766766

767+
// Only stdout is redirected: the error capture is reached exclusively through
768+
// ErrorAnsiConsole, mirroring how Program.cs keeps the two streams apart.
769+
Console.SetOut(outputCapture);
770+
767771
AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
768772
{
769773
Ansi = AnsiSupport.Yes,
@@ -871,7 +875,6 @@ internal sealed class OutputCapture : TextWriter, IDisposable
871875
public OutputCapture(TextWriter textWriter)
872876
{
873877
_stdOutWriter = textWriter;
874-
Console.SetOut(this);
875878
Captured = new StringWriter();
876879
}
877880

MSStore.CLI.UnitTests/PackageCommandUnitTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public async Task PackageCommandForUWPAppsShouldCallMSBuildWithOutputParameterIf
9999
]);
100100

101101
result.Error.Should().Contain("The packaged app is here:");
102-
result.Output.Should().Contain(customPath);
102+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(customPath));
103103

104104
ExternalCommandExecutor.VerifyAll();
105105
}
@@ -216,7 +216,7 @@ public async Task PackageCommandForWinUIAppsShouldCallMSBuildWithOutputParameter
216216
]);
217217

218218
result.Error.Should().Contain("The packaged app is here:");
219-
result.Output.Should().Contain(customPath);
219+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(customPath));
220220

221221
ExternalCommandExecutor.VerifyAll();
222222
}
@@ -295,7 +295,7 @@ public async Task PackageCommandForMauiAppsShouldCallMSBuildWithOutputParameterI
295295
]);
296296

297297
result.Error.Should().Contain("The packaged app is here:");
298-
result.Output.Should().Contain(customPath);
298+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(customPath));
299299

300300
ExternalCommandExecutor.VerifyAll();
301301
}
@@ -375,7 +375,7 @@ public async Task PackageCommandForFlutterAppsShouldCallFlutter()
375375
]);
376376

377377
result.Error.Should().Contain("The packaged app is here:");
378-
result.Output.Should().Contain(path);
378+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(path));
379379
}
380380

381381
[TestMethod]
@@ -426,7 +426,7 @@ public async Task PackageCommandForFlutterAppsShouldCallFlutterWithOutputParamet
426426
]);
427427

428428
result.Error.Should().Contain("The packaged app is here:");
429-
result.Output.Should().Contain(customPath);
429+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(customPath));
430430
}
431431

432432
private void SetupPubGet(DirectoryInfo dirInfo)
@@ -478,7 +478,7 @@ public async Task PackageCommandForElectronNpmAppsShouldCallElectronNpm()
478478
]);
479479

480480
result.Error.Should().Contain("The packaged app is here:");
481-
result.Output.Should().Contain(path);
481+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(path));
482482
}
483483

484484
[TestMethod]
@@ -514,7 +514,7 @@ public async Task PackageCommandForElectronYarnAppsShouldCallElectronYarn()
514514
]);
515515

516516
result.Error.Should().Contain("The packaged app is here:");
517-
result.Output.Should().Contain(path);
517+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(path));
518518
}
519519

520520
[TestMethod]
@@ -566,7 +566,7 @@ public async Task PackageCommandForReactNativeNpmAppsShouldCallMSBuild(string ma
566566
]);
567567

568568
result.Error.Should().Contain("The packaged app is here:");
569-
result.Output.Should().Contain(path);
569+
result.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Should().ContainSingle(line => line.Contains(path));
570570
}
571571
}
572572
}

MSStore.CLI.UnitTests/SubmissionCommandPackagedUnitTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using System.Text.Json;
45
using MSStore.API.Packaged.Models;
56

67
namespace MSStore.CLI.UnitTests
@@ -68,6 +69,40 @@ public async Task PackagedSubmissionGetCommand()
6869
result.Output.Should().Contain("\"FileUploadUrl\": \"https://azureblob.com/fileupload\"");
6970
}
7071

72+
[TestMethod]
73+
public async Task PackagedSubmissionGetCommandShouldNotWrapJsonOutput()
74+
{
75+
// Longer than the width the test console renders at, and sprinkled with characters
76+
// that the serializer escapes as \uXXXX, so a wrap would both break the JSON and
77+
// corrupt the description.
78+
var longDescription = string.Concat(
79+
Enumerable.Repeat("Sync your mail & calendar across every device without a fuss. ", 12));
80+
81+
AddDefaultFakeSubmission(longDescription);
82+
83+
FakeApps[0].LastPublishedApplicationSubmission = new ApplicationSubmissionInfo
84+
{
85+
Id = "123456789"
86+
};
87+
88+
var result = await ParseAndInvokeAsync(
89+
[
90+
"submission",
91+
"get",
92+
FakeApps[0].Id!
93+
]);
94+
95+
using var json = JsonDocument.Parse(result.Output);
96+
97+
json.RootElement
98+
.GetProperty("Listings")
99+
.GetProperty("en-us")
100+
.GetProperty("BaseListing")
101+
.GetProperty("Description")
102+
.GetString()
103+
.Should().Be(longDescription);
104+
}
105+
71106
[TestMethod]
72107
public async Task PackagedSubmissionGetListingAssetsCommand()
73108
{

MSStore.CLI.UnitTests/SubmissionCommandUnpackagedUnitTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using System.Text.Json;
45
using MSStore.API.Models;
56

67
namespace MSStore.CLI.UnitTests
@@ -68,6 +69,50 @@ public async Task UnpackagedSubmissionGetCommand()
6869
result.Output.Should().Contain("\"PackageId\": \"12345\"");
6970
}
7071

72+
[TestMethod]
73+
public async Task UnpackagedSubmissionGetCommandShouldNotWrapJsonOutput()
74+
{
75+
// Longer than the width the test console renders at, and sprinkled with characters
76+
// that the serializer escapes as \uXXXX, so a wrap would both break the JSON and
77+
// corrupt the description.
78+
var longDescription = string.Concat(
79+
Enumerable.Repeat("Sync your mail & calendar across every device without a fuss. ", 12));
80+
81+
FakeStoreAPI
82+
.Setup(x => x.GetDraftAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
83+
.ReturnsAsync(new ResponseWrapper<ListingsMetadataResponse>
84+
{
85+
IsSuccess = true,
86+
ResponseData = new ListingsMetadataResponse
87+
{
88+
Listings =
89+
[
90+
new Listing
91+
{
92+
Language = "en-us",
93+
Description = longDescription
94+
}
95+
]
96+
}
97+
});
98+
99+
var result = await ParseAndInvokeAsync(
100+
[
101+
"submission",
102+
"get",
103+
Guid.Empty.ToString()
104+
]);
105+
106+
using var json = JsonDocument.Parse(result.Output);
107+
108+
json.RootElement
109+
.GetProperty("ResponseData")
110+
.GetProperty("Listings")[0]
111+
.GetProperty("Description")
112+
.GetString()
113+
.Should().Be(longDescription);
114+
}
115+
71116
[TestMethod]
72117
public async Task UnpackagedSubmissionGetListingAssetsCommand()
73118
{

MSStore.CLI/Commands/Apps/GetCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
9494
}
9595
else
9696
{
97-
AnsiConsole.WriteLine(JsonSerializer.Serialize(app, app.GetType(), SourceGenerationContext.GetCustom(true)));
97+
StandardOutput.WriteLine(JsonSerializer.Serialize(app, app.GetType(), SourceGenerationContext.GetCustom(true)));
9898
return await _telemetryClient.TrackCommandEventAsync<Handler>(0, ct);
9999
}
100100
}

MSStore.CLI/Commands/Flights/CreateCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
111111

112112
if (flight != null)
113113
{
114-
AnsiConsole.WriteLine(JsonSerializer.Serialize(flight, SourceGenerationContext.GetCustom(true).DevCenterFlight));
114+
StandardOutput.WriteLine(JsonSerializer.Serialize(flight, SourceGenerationContext.GetCustom(true).DevCenterFlight));
115115
return await _telemetryClient.TrackCommandEventAsync<Handler>(0, ct);
116116
}
117117

MSStore.CLI/Commands/Flights/GetCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
7575

7676
if (flight != null)
7777
{
78-
AnsiConsole.WriteLine(JsonSerializer.Serialize(flight, SourceGenerationContext.GetCustom(true).DevCenterFlight));
78+
StandardOutput.WriteLine(JsonSerializer.Serialize(flight, SourceGenerationContext.GetCustom(true).DevCenterFlight));
7979
return await _telemetryClient.TrackCommandEventAsync<Handler>(0, ct);
8080
}
8181

MSStore.CLI/Commands/Flights/Submission/GetCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
8888
return await _telemetryClient.TrackCommandEventAsync<Handler>(productId, -1, ct);
8989
}
9090

91-
AnsiConsole.WriteLine(JsonSerializer.Serialize(flightSubmission, SourceGenerationContext.GetCustom(true).DevCenterFlightSubmission));
91+
StandardOutput.WriteLine(JsonSerializer.Serialize(flightSubmission, SourceGenerationContext.GetCustom(true).DevCenterFlightSubmission));
9292

9393
return await _telemetryClient.TrackCommandEventAsync<Handler>(productId, 0, ct);
9494
}

MSStore.CLI/Commands/Flights/Submission/Rollout/FinalizeCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
101101
return await _telemetryClient.TrackCommandEventAsync<Handler>(productId, -1, ct);
102102
}
103103

104-
AnsiConsole.WriteLine(JsonSerializer.Serialize(flightSubmissionRollout, SourceGenerationContext.GetCustom(true).PackageRollout));
104+
StandardOutput.WriteLine(JsonSerializer.Serialize(flightSubmissionRollout, SourceGenerationContext.GetCustom(true).PackageRollout));
105105

106106
return await _telemetryClient.TrackCommandEventAsync<Handler>(productId, 0, ct);
107107
}

MSStore.CLI/Commands/Flights/Submission/Rollout/GetCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public override async Task<int> InvokeAsync(ParseResult parseResult, Cancellatio
101101
return await _telemetryClient.TrackCommandEventAsync<Handler>(productId, -1, ct);
102102
}
103103

104-
AnsiConsole.WriteLine(JsonSerializer.Serialize(flightSubmissionRollout, SourceGenerationContext.GetCustom(true).PackageRollout));
104+
StandardOutput.WriteLine(JsonSerializer.Serialize(flightSubmissionRollout, SourceGenerationContext.GetCustom(true).PackageRollout));
105105

106106
return await _telemetryClient.TrackCommandEventAsync<Handler>(productId, 0, ct);
107107
}

0 commit comments

Comments
 (0)