Allow Run to handle commandlines with spaces (#42016)

This better handles cases where commandlines might have embedded spaces.
For something like

```
C:\Program Files\PowerShell\7\pwsh.exe -c write-host dawg
```

we'll now see that `C:\Program` isn't a file, and we'll try to look at
`C:\Program Files\PowerShell\7\pwsh.exe` instead.

This code is pilfered from
https://github.com/microsoft/terminal/pull/12348 which fixed
https://github.com/microsoft/terminal/issues/12345. Terminal has great
code for normalizing a string into an executable and args, so why not
just use it here.

related to #41646
related to #41705 (but much more narrowly scoped)

----

I added some tests too.

drive-by fix: as I was adding tests, I added a helper for "make a change
to a page, and await the page's ItemsChanged". This removes a bunch of
`await 1s` calls, and brings the shell page tests from like, 7s to 500ms
This commit is contained in:
Mike Griese
2025-09-26 18:39:00 -05:00
committed by GitHub
parent 2b6c5d2cdd
commit c3398b0a01
7 changed files with 425 additions and 22 deletions

View File

@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Shell.UnitTests;
[TestClass]
public class NormalizeCommandLineTests : CommandPaletteUnitTestBase
{
private void NormalizeTestCore(string input, string expectedExe, string expectedArgs = "")
{
ShellListPageHelpers.NormalizeCommandLineAndArgs(input, out var exe, out var args);
Assert.AreEqual(expectedExe, exe, ignoreCase: true, culture: System.Globalization.CultureInfo.InvariantCulture);
Assert.AreEqual(expectedArgs, args);
}
[TestMethod]
[DataRow("ping bing.com", "c:\\Windows\\system32\\ping.exe", "bing.com")]
[DataRow("curl bing.com", "c:\\Windows\\system32\\curl.exe", "bing.com")]
[DataRow("ipconfig /all", "c:\\Windows\\system32\\ipconfig.exe", "/all")]
public void NormalizeCommandLineSimple(string input, string expectedExe, string expectedArgs = "")
{
NormalizeTestCore(input, expectedExe, expectedArgs);
}
[TestMethod]
[DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")]
[DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")]
public void NormalizeCommandLineSpacesInExecutablePath(string input, string expectedExe, string expectedArgs = "")
{
NormalizeTestCore(input, expectedExe, expectedArgs);
}
[TestMethod]
[DataRow("%SystemRoot%\\system32\\cmd.exe", "C:\\Windows\\System32\\cmd.exe")]
public void NormalizeWithEnvVar(string input, string expectedExe, string expectedArgs = "")
{
NormalizeTestCore(input, expectedExe, expectedArgs);
}
[TestMethod]
[DataRow("cmd --run --test", "C:\\Windows\\System32\\cmd.exe", "--run --test")]
[DataRow("cmd --run --test ", "C:\\Windows\\System32\\cmd.exe", "--run --test")]
[DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "--run --test --pass")]
public void NormalizeArgsWithSpaces(string input, string expectedExe, string expectedArgs = "")
{
NormalizeTestCore(input, expectedExe, expectedArgs);
}
[TestMethod]
[DataRow("ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "")]
[DataRow("C:\\ThisPathDoesNotExist\\NoExecutable.exe", "C:\\ThisPathDoesNotExist\\NoExecutable.exe", "")]
public void NormalizeNonExistentExecutable(string input, string expectedExe, string expectedArgs = "")
{
NormalizeTestCore(input, expectedExe, expectedArgs);
}
[TestMethod]
[DataRow("C:\\Windows", "c:\\Windows", "")]
[DataRow("C:\\Windows foo /bar", "c:\\Windows", "foo /bar")]
public void NormalizeDirectoryAsExecutable(string input, string expectedExe, string expectedArgs = "")
{
NormalizeTestCore(input, expectedExe, expectedArgs);
}
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -74,6 +75,8 @@ public class QueryTests : CommandPaletteUnitTestBase
[DataRow("ping bing.com", "ping.exe")]
[DataRow("curl bing.com", "curl.exe")]
[DataRow("ipconfig /all", "ipconfig.exe")]
[DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "MsMpEng.exe")]
[DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "MsMpEng.exe")]
public async Task QueryWithoutHistoryCommand(string command, string exeName)
{
// Setup
@@ -82,19 +85,24 @@ public class QueryTests : CommandPaletteUnitTestBase
var pages = new ShellListPage(settings, mockHistory.Object);
pages.UpdateSearchText(string.Empty, command);
// wait for about 1s.
await Task.Delay(1000);
await UpdatePageAndWaitForItems(pages, () =>
{
// Test: Search for a command that exists in history
pages.UpdateSearchText(string.Empty, command);
});
var commandList = pages.GetItems();
Assert.AreEqual(1, commandList.Length);
var executeCommand = commandList.FirstOrDefault();
Assert.IsNotNull(executeCommand);
Assert.IsNotNull(executeCommand.Icon);
Assert.IsTrue(executeCommand.Title.Contains(exeName), $"expect ${exeName} but got ${executeCommand.Title}");
var listItem = commandList.FirstOrDefault();
Assert.IsNotNull(listItem);
var runExeListItem = listItem as RunExeItem;
Assert.IsNotNull(runExeListItem);
Assert.AreEqual(exeName, runExeListItem.Exe);
Assert.IsTrue(listItem.Title.Contains(exeName), $"expect ${exeName} but got ${listItem.Title}");
Assert.IsNotNull(listItem.Icon);
}
[TestMethod]
@@ -109,10 +117,11 @@ public class QueryTests : CommandPaletteUnitTestBase
var pages = new ShellListPage(settings, mockHistoryService.Object);
// Test: Search for a command that exists in history
pages.UpdateSearchText(string.Empty, command);
await Task.Delay(1000);
await UpdatePageAndWaitForItems(pages, () =>
{
// Test: Search for a command that exists in history
pages.UpdateSearchText(string.Empty, command);
});
var commandList = pages.GetItems();
@@ -134,9 +143,11 @@ public class QueryTests : CommandPaletteUnitTestBase
var pages = new ShellListPage(settings, mockHistoryService.Object);
pages.UpdateSearchText("abcdefg", string.Empty);
await Task.Delay(1000);
await UpdatePageAndWaitForItems(pages, () =>
{
// Test: Search for a command that exists in history
pages.UpdateSearchText("abcdefg", string.Empty);
});
var commandList = pages.GetItems();

View File

@@ -2,7 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,10 +18,23 @@ public class CommandPaletteUnitTestBase
public IListItem[] Query(string query, IListItem[] candidates)
{
IListItem[] listItems = candidates
var listItems = candidates
.Where(item => MatchesFilter(query, item))
.ToArray();
return listItems;
}
public async Task UpdatePageAndWaitForItems(IDynamicListPage page, Action modification)
{
// Add an event handler for the ItemsChanged event,
// Then call the modification action,
// and wait for the event to be raised.
var tcs = new TaskCompletionSource<object>();
page.ItemsChanged += (sender, args) => tcs.SetResult(null);
modification();
await tcs.Task;
}
}