Skip to content

Commit 79c1539

Browse files
FH-InwayCopilot
andauthored
🐛 fix powershell dotnet objects output regression issue #4024 (#4100)
* Implement PowerShell formatting fallback with custom formatter detection Co-authored-by: FH-Inway <[email protected]> * fix tests by using default text/html mime type * add handling of formatting objects * add more specific assertions to show that table formatting is used * reset formatter after every test * revert Jupyter test back to expected Stream output Test was changed in commit 74cd6aa as part of #3912. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]>
1 parent df8168b commit 79c1539

File tree

3 files changed

+140
-9
lines changed

3 files changed

+140
-9
lines changed

src/Microsoft.DotNet.Interactive.Jupyter.Tests/ExecuteRequestHandlerTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,9 @@ public async Task sends_InputRequest_message_when_submission_requests_user_passw
403403

404404
JupyterMessageSender.RequestMessages.Should().Contain(r => r.Prompt == prompt && r.Password == true);
405405
JupyterMessageSender.PubSubMessages
406-
.OfType<Protocol.DisplayData>()
406+
.OfType<Protocol.Stream>()
407407
.Should()
408-
.Contain(s => s.Data.ContainsKey("text/html") && s.Data["text/html"].ToString().Contains("System.Security.SecureString"));
408+
.Contain(s => s.Name == Protocol.Stream.StandardOutput && s.Text.Contains("System.Security.SecureString"));
409409
}
410410

411411
[Fact]

src/Microsoft.DotNet.Interactive.PowerShell.Tests/PowerShellKernelTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ public class PowerShellKernelTests : LanguageKernelTestBase
7777

7878
public PowerShellKernelTests(ITestOutputHelper output) : base(output)
7979
{
80+
DisposeAfterTest(() => Formatter.ResetToDefault());
8081
}
8182

8283
[Theory]
@@ -417,4 +418,70 @@ public async Task Powershell_CustomObject_Is_Formatted_Correctly(string mimeType
417418
.Should()
418419
.BeEquivalentHtmlTo(_tableOutputOfCustomObjectTest);
419420
}
421+
422+
[Fact]
423+
public async Task PowerShell_objects_without_custom_formatters_use_native_formatting()
424+
{
425+
// Arrange
426+
var kernel = CreateKernel(Language.PowerShell);
427+
428+
// Act - Get-Process returns objects without custom formatters and should use PowerShell native formatting
429+
var result = await kernel.SendAsync(new SubmitCode("Get-Process | Select-Object -First 1"));
430+
431+
// Assert - Should produce StandardOutputValueProduced (native PowerShell formatting) not DisplayedValueProduced
432+
var outputs = result.Events.OfType<StandardOutputValueProduced>().ToList();
433+
outputs.Should().NotBeEmpty("Get-Process output should use native PowerShell formatting");
434+
435+
// The output should contain typical PowerShell table formatting with headers and dashes separator
436+
var allOutput = string.Join("", outputs.SelectMany(e => e.FormattedValues.Select(v => v.Value)));
437+
allOutput.Should().NotBeEmpty()
438+
.And.ContainAny("NPM", "PM", "WS", "CPU", "Id", "SI", "ProcessName") // Common Get-Process column headers
439+
.And.Match("*---*"); // PowerShell table separator line
440+
}
441+
442+
[Fact]
443+
public async Task PowerShell_objects_with_custom_formatters_use_custom_formatting()
444+
{
445+
// Arrange
446+
var kernel = CreateKernel(Language.PowerShell);
447+
448+
// Register a custom formatter for FileInfo
449+
// Note: context.Display() produces HTML output by default, so we register for text/html
450+
Formatter.Register<FileInfo>((fileInfo, writer) =>
451+
{
452+
writer.Write($"CUSTOM: {fileInfo.Name}");
453+
}, HtmlFormatter.MimeType);
454+
455+
// Act - Create a FileInfo object which now has a custom formatter
456+
var result = await kernel.SendAsync(new SubmitCode("[System.IO.FileInfo]::new('test.txt')"));
457+
458+
// Assert - Should produce DisplayedValueProduced (custom formatter used)
459+
var displayedValues = result.Events.OfType<DisplayedValueProduced>().ToList();
460+
displayedValues.Should().ContainSingle("FileInfo with custom formatter should use Display");
461+
462+
var htmlValue = displayedValues.First().FormattedValues.FirstOrDefault(f => f.MimeType == HtmlFormatter.MimeType);
463+
htmlValue.Should().NotBeNull("HTML formatted value should be present");
464+
htmlValue.Value.Should().Contain("CUSTOM: test.txt");
465+
}
466+
467+
[Fact]
468+
public async Task PowerShell_Format_Table_works_with_native_formatting()
469+
{
470+
// Arrange
471+
var kernel = CreateKernel(Language.PowerShell);
472+
473+
// Act - Use Format-Table explicitly
474+
var result = await kernel.SendAsync(new SubmitCode(
475+
"[pscustomobject]@{ Name='Item1'; Value=10 },[pscustomobject]@{ Name='Item2'; Value=20 } | Format-Table"));
476+
477+
// Assert - Should produce StandardOutputValueProduced with table formatting
478+
var outputs = result.Events.OfType<StandardOutputValueProduced>().ToList();
479+
outputs.Should().NotBeEmpty("Format-Table output should use native PowerShell formatting");
480+
481+
var allOutput = string.Join("", outputs.SelectMany(e => e.FormattedValues.Select(v => v.Value)));
482+
allOutput.Should().Contain("Name")
483+
.And.Contain("Value")
484+
.And.Match("*---*") // PowerShell table separator line
485+
.And.ContainAny("Item1", "Item2"); // Data values
486+
}
420487
}

src/Microsoft.DotNet.Interactive.PowerShell/PowerShellKernel.cs

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,35 @@ private async Task RunInAzShell(string code)
378378
}
379379
}
380380

381+
private bool HasCustomFormatter(object value)
382+
{
383+
if (value is null)
384+
{
385+
return false;
386+
}
387+
388+
var valueType = value.GetType();
389+
var userFormatters = Formatter.RegisteredFormatters(includeDefaults: false);
390+
var interfaces = valueType.GetInterfaces();
391+
392+
return userFormatters.Any(f =>
393+
f.Type == valueType ||
394+
f.Type.IsAssignableFrom(valueType) ||
395+
Array.IndexOf(interfaces, f.Type) >= 0);
396+
}
397+
398+
private static bool IsFormattingObject(PSObject psObject)
399+
{
400+
// Check if this is a formatting object from Format-Table, Format-List, etc.
401+
// These objects are in the Microsoft.PowerShell.Commands.Internal.Format namespace
402+
if (psObject?.BaseObject != null)
403+
{
404+
var typeName = psObject.BaseObject.GetType().FullName;
405+
return typeName?.StartsWith("Microsoft.PowerShell.Commands.Internal.Format.") == true;
406+
}
407+
return false;
408+
}
409+
381410
internal bool RunLocally(string code, out string errorMessage, bool suppressOutput = false, KernelInvocationContext context = null)
382411
{
383412
var command = new Command(code, isScript: true);
@@ -398,18 +427,53 @@ internal bool RunLocally(string code, out string errorMessage, bool suppressOutp
398427

399428
if (!suppressOutput && context is not null)
400429
{
401-
foreach (var item in result)
430+
// Check if the result contains formatting objects (from Format-Table, Format-List, etc.)
431+
// These need to be processed as a complete pipeline, not individually
432+
if (result.Count > 0 && IsFormattingObject(result[0]))
402433
{
403-
var value = item is PSObject ps ? ps.Unwrap() : item;
434+
// Pass all formatting objects to Out-String at once
435+
Pwsh.AddCommand("Out-String");
436+
var formattedResult = Pwsh.Invoke(result);
437+
Pwsh.Commands.Clear();
404438

405-
if (item.TypeNames[0] == "System.String")
439+
if (formattedResult.Count > 0)
406440
{
407-
var formatted = new FormattedValue("text/plain", value + Environment.NewLine);
408-
context.Publish(new StandardOutputValueProduced(context.Command, new[] { formatted } ));
441+
var output = string.Concat(formattedResult);
442+
var formatted = new FormattedValue("text/plain", output);
443+
context.Publish(new StandardOutputValueProduced(context.Command, new[] { formatted }));
409444
}
410-
else
445+
}
446+
else
447+
{
448+
// Process each item individually
449+
foreach (var item in result)
411450
{
412-
context.Display(value);
451+
var value = item is PSObject ps ? ps.Unwrap() : item;
452+
453+
if (item.TypeNames[0] == "System.String")
454+
{
455+
var formatted = new FormattedValue("text/plain", value + Environment.NewLine);
456+
context.Publish(new StandardOutputValueProduced(context.Command, new[] { formatted } ));
457+
}
458+
else if (HasCustomFormatter(value))
459+
{
460+
// Use custom formatter
461+
context.Display(value);
462+
}
463+
else
464+
{
465+
// Use PowerShell native formatting
466+
Pwsh.AddCommand(_outDefaultCommand)
467+
.AddParameter("InputObject", item);
468+
Pwsh.AddCommand("Out-String");
469+
var formattedResult = Pwsh.InvokeAndClear();
470+
if (formattedResult.Count > 0)
471+
{
472+
var output = string.Concat(formattedResult);
473+
var formatted = new FormattedValue("text/plain", output);
474+
context.Publish(new StandardOutputValueProduced(context.Command, new[] { formatted }));
475+
}
476+
}
413477
}
414478
}
415479
}

0 commit comments

Comments
 (0)