[UI automation test] Add basic tests case for powerrename module. (#40393)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
1. Add command args support in ui test core
2. Add command line parse logic in powerrename
3. Add some test cases.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] **Closes:** #xxx
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
This commit is contained in:
Yu Leng
2025-07-09 14:32:40 +08:00
committed by GitHub
parent c4922f1b30
commit 802bc3bd34
12 changed files with 568 additions and 138 deletions

View File

@@ -6,6 +6,8 @@
#include <vector>
#include <string>
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <common/logger/logger.h>
#include <common/logger/logger_settings.h>
@@ -28,6 +30,61 @@ std::vector<std::wstring> g_files;
const std::wstring moduleName = L"PowerRename";
// Helper function to parse command line arguments for file paths
std::vector<std::wstring> ParseCommandLineArgs(const std::wstring& commandLine)
{
std::vector<std::wstring> filePaths;
// Skip executable name
size_t argsStart = 0;
if (!commandLine.empty() && commandLine[0] == L'"')
{
argsStart = commandLine.find(L'"', 1);
if (argsStart != std::wstring::npos) argsStart++;
}
else
{
argsStart = commandLine.find_first_of(L" \t");
}
if (argsStart == std::wstring::npos) return filePaths;
// Get the arguments part
std::wstring args = commandLine.substr(argsStart);
// Simple split with quote handling
std::wstring current;
bool inQuotes = false;
for (wchar_t ch : args)
{
if (ch == L'"')
{
inQuotes = !inQuotes;
}
else if ((ch == L' ' || ch == L'\t') && !inQuotes)
{
if (!current.empty())
{
filePaths.push_back(current);
current.clear();
}
}
else
{
current += ch;
}
}
// Add the last argument if any
if (!current.empty())
{
filePaths.push_back(current);
}
return filePaths;
}
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
@@ -69,131 +126,147 @@ void App::OnLaunched(LaunchActivatedEventArgs const&)
}
auto args = std::wstring{ GetCommandLine() };
size_t pos{ args.rfind(L"\\\\.\\pipe\\") };
std::wstring pipe_name;
if (pos != std::wstring::npos)
// Try to parse command line arguments first
std::vector<std::wstring> cmdLineFiles = ParseCommandLineArgs(args);
if (!cmdLineFiles.empty())
{
pipe_name = args.substr(pos);
}
HANDLE hStdin;
if (pipe_name.size() > 0)
{
while (1)
// Use command line arguments for UI testing
for (const auto& filePath : cmdLineFiles)
{
hStdin = CreateFile(
pipe_name.c_str(), // pipe name
GENERIC_READ | GENERIC_WRITE, // read and write
0, // no sharing
NULL, // default security attributes
OPEN_EXISTING, // opens existing pipe
0, // default attributes
NULL); // no template file
// Break if the pipe handle is valid.
if (hStdin != INVALID_HANDLE_VALUE)
break;
// Exit if an error other than ERROR_PIPE_BUSY occurs.
auto error = GetLastError();
if (error != ERROR_PIPE_BUSY)
{
break;
}
if (!WaitNamedPipe(pipe_name.c_str(), 3))
{
printf("Could not open pipe: 20 second wait timed out.");
}
g_files.push_back(filePath);
}
Logger::debug(L"Starting PowerRename with {} files from command line", g_files.size());
}
else
{
hStdin = GetStdHandle(STD_INPUT_HANDLE);
}
// Use original pipe/stdin logic for normal operation
size_t pos{ args.rfind(L"\\\\.\\pipe\\") };
if (hStdin == INVALID_HANDLE_VALUE)
{
Logger::error(L"Invalid input handle.");
ExitProcess(1);
}
std::wstring pipe_name;
if (pos != std::wstring::npos)
{
pipe_name = args.substr(pos);
}
HANDLE hStdin;
if (pipe_name.size() > 0)
{
while (1)
{
hStdin = CreateFile(
pipe_name.c_str(), // pipe name
GENERIC_READ | GENERIC_WRITE, // read and write
0, // no sharing
NULL, // default security attributes
OPEN_EXISTING, // opens existing pipe
0, // default attributes
NULL); // no template file
// Break if the pipe handle is valid.
if (hStdin != INVALID_HANDLE_VALUE)
break;
// Exit if an error other than ERROR_PIPE_BUSY occurs.
auto error = GetLastError();
if (error != ERROR_PIPE_BUSY)
{
break;
}
if (!WaitNamedPipe(pipe_name.c_str(), 3))
{
printf("Could not open pipe: 20 second wait timed out.");
}
}
}
else
{
hStdin = GetStdHandle(STD_INPUT_HANDLE);
}
if (hStdin == INVALID_HANDLE_VALUE)
{
Logger::error(L"Invalid input handle.");
ExitProcess(1);
}
#ifdef DEBUG_BENCHMARK_100K_ENTRIES
const std::wstring_view ROOT_PATH = L"R:\\PowerRenameBenchmark";
const std::wstring_view ROOT_PATH = L"R:\\PowerRenameBenchmark";
std::wstring subdirectory_name = L"0";
std::error_code _;
std::wstring subdirectory_name = L"0";
std::error_code _;
#if 1
constexpr bool recreate_files = true;
constexpr bool recreate_files = true;
#else
constexpr bool recreate_files = false;
constexpr bool recreate_files = false;
#endif
if constexpr (recreate_files)
fs::remove_all(ROOT_PATH, _);
g_files.push_back(fs::path{ ROOT_PATH });
constexpr int pow2_threshold = 10;
constexpr int num_files = 100'000;
for (int i = 0; i < num_files; ++i)
{
fs::path file_path{ ROOT_PATH };
// Create a subdirectory for each subsequent 2^pow2_threshold files, o/w filesystem becomes too slow to create them in a reasonable time.
if ((i & ((1 << pow2_threshold) - 1)) == 0)
{
subdirectory_name = std::to_wstring(i >> pow2_threshold);
}
file_path /= subdirectory_name;
if constexpr (recreate_files)
fs::remove_all(ROOT_PATH, _);
g_files.push_back(fs::path{ ROOT_PATH });
constexpr int pow2_threshold = 10;
constexpr int num_files = 100'000;
for (int i = 0; i < num_files; ++i)
{
fs::create_directories(file_path, _);
file_path /= std::to_wstring(i) + L".txt";
HANDLE hFile = CreateFileW(
file_path.c_str(),
GENERIC_WRITE,
0,
nullptr,
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
nullptr);
CloseHandle(hFile);
fs::path file_path{ ROOT_PATH };
// Create a subdirectory for each subsequent 2^pow2_threshold files, o/w filesystem becomes too slow to create them in a reasonable time.
if ((i & ((1 << pow2_threshold) - 1)) == 0)
{
subdirectory_name = std::to_wstring(i >> pow2_threshold);
}
file_path /= subdirectory_name;
if constexpr (recreate_files)
{
fs::create_directories(file_path, _);
file_path /= std::to_wstring(i) + L".txt";
HANDLE hFile = CreateFileW(
file_path.c_str(),
GENERIC_WRITE,
0,
nullptr,
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
nullptr);
CloseHandle(hFile);
}
}
}
#else
#define BUFSIZE 4096 * 4
BOOL bSuccess;
WCHAR chBuf[BUFSIZE];
DWORD dwRead;
for (;;)
{
// Read from standard input and stop on error or no data.
bSuccess = ReadFile(hStdin, chBuf, BUFSIZE * sizeof(wchar_t), &dwRead, NULL);
if (!bSuccess || dwRead == 0)
break;
std::wstring inputBatch{ chBuf, dwRead / sizeof(wchar_t) };
std::wstringstream ss(inputBatch);
std::wstring item;
wchar_t delimiter = '?';
while (std::getline(ss, item, delimiter))
BOOL bSuccess;
WCHAR chBuf[BUFSIZE];
DWORD dwRead;
for (;;)
{
g_files.push_back(item);
// Read from standard input and stop on error or no data.
bSuccess = ReadFile(hStdin, chBuf, BUFSIZE * sizeof(wchar_t), &dwRead, NULL);
if (!bSuccess || dwRead == 0)
break;
std::wstring inputBatch{ chBuf, dwRead / sizeof(wchar_t) };
std::wstringstream ss(inputBatch);
std::wstring item;
wchar_t delimiter = '?';
while (std::getline(ss, item, delimiter))
{
g_files.push_back(item);
}
if (!bSuccess)
break;
}
if (!bSuccess)
break;
}
CloseHandle(hStdin);
CloseHandle(hStdin);
#endif
Logger::debug(L"Starting PowerRename with {} files selected", g_files.size());
Logger::debug(L"Starting PowerRename with {} files from pipe/stdin", g_files.size());
}
window = make<MainWindow>();
window.Activate();

View File

@@ -0,0 +1,87 @@
// 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 System;
using System.Collections.Generic;
using System.Drawing.Text;
using System.IO;
using System.Reflection;
using Microsoft.PowerToys.UITest;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerRename.UITests;
/// <summary>
/// Initializes a new instance of the <see cref="BasicRenameTests"/> class.
/// Initialize PowerRename UITest with custom test file paths
/// </summary>
/// <param name="testFilePaths">Array of file/folder paths to test with</param>
[TestClass]
public class BasicRenameTests : PowerRenameUITestBase
{
/// <summary>
/// Initializes a new instance of the <see cref="BasicRenameTests"/> class.
/// Initialize PowerRename UITest with default test files
/// </summary>
public BasicRenameTests()
: base()
{
}
[TestMethod]
public void BasicInput()
{
this.SetSearchBoxText("search");
this.SetReplaceBoxText("replace");
}
[TestMethod]
public void BasicMatchFileName()
{
this.SetSearchBoxText("testCase1");
this.SetReplaceBoxText("replaced");
Assert.IsTrue(this.Find<TextBlock>("replaced.txt").Text == "replaced.txt");
}
[TestMethod]
public void BasicRegularMatch()
{
this.SetSearchBoxText("^test.*\\.txt$");
this.SetReplaceBoxText("matched.txt");
CheckOriginalOrRenamedCount(0);
this.SetRegularExpressionCheckbox(true);
CheckOriginalOrRenamedCount(2);
Assert.IsTrue(this.Find<TextBlock>("matched.txt").Text == "matched.txt");
}
[TestMethod]
public void BasicMatchAllOccurrences()
{
this.SetSearchBoxText("t");
this.SetReplaceBoxText("f");
this.SetMatchAllOccurrencesCheckbox(true);
Assert.IsTrue(this.Find<TextBlock>("fesfCase2.fxf").Text == "fesfCase2.fxf");
Assert.IsTrue(this.Find<TextBlock>("fesfCase1.fxf").Text == "fesfCase1.fxf");
}
[TestMethod]
public void BasicCaseSensitive()
{
this.SetSearchBoxText("testcase1");
this.SetReplaceBoxText("match1");
CheckOriginalOrRenamedCount(1);
Assert.IsTrue(this.Find<TextBlock>("match1.txt").Text == "match1.txt");
this.SetCaseSensitiveCheckbox(true);
CheckOriginalOrRenamedCount(0);
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<RootNamespace>PowerRename.UITests</RootNamespace>
<AssemblyName>PowerRename.UITests</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
<!-- This is a UI test, so don't run as part of MSBuild -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<PropertyGroup>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerRename.UITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\..\codeAnalysis\GlobalSuppressions.cs" Link="GlobalSuppressions.cs" />
</ItemGroup>
<ItemGroup>
<None Include="BasicRenameTests.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
<PackageReference Include="System.Net.Http" />
<PackageReference Include="System.Private.Uri" />
<PackageReference Include="System.Text.RegularExpressions" />
<ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Copy testItems folder and all contents to output directory -->
<Content Include="testItems\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,200 @@
// 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 System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Microsoft.PowerToys.UITest;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerRename.UITests;
[TestClass]
public class PowerRenameUITestBase : UITestBase
{
private static readonly string[] OriginalTestFilePaths = new string[]
{
Path.Combine("testItems", "folder1"), // Test folder
Path.Combine("testItems", "folder2"), // Test folder
Path.Combine("testItems", "testCase1.txt"), // Test file
};
private static readonly string BaseTestFileFolderPath = Path.Combine(Assembly.GetExecutingAssembly().Location, "..", "test", typeof(BasicRenameTests).Name);
private static List<string> TestFilesAndFoldersArray { get; } = InitCleanTestEnvironment();
private static List<string> InitCleanTestEnvironment()
{
var testFilesAndFolders = new List<string>
{
};
foreach (var files in OriginalTestFilePaths)
{
var targetFolder = Path.Combine(BaseTestFileFolderPath, files);
testFilesAndFolders.Add(targetFolder);
}
return testFilesAndFolders;
}
[TestInitialize]
public void InitTestCase()
{
// Clean up any existing test directories for this test class
CleanupTestDirectories();
// copy files and folders from OriginalTestFilePaths to testFilesAndFoldersArray
CopyTestFilesToDestination();
RestartScopeExe();
}
/// <summary>
/// Initializes a new instance of the <see cref="PowerRenameUITestBase"/> class.
/// Initialize PowerRename UITest with default test files
/// </summary>
public PowerRenameUITestBase()
: base(PowerToysModule.PowerRename, WindowSize.UnSpecified, TestFilesAndFoldersArray.ToArray())
{
}
/// <summary>
/// Clean up any existing test directories for the specified test class
/// </summary>
private static void CleanupTestDirectories()
{
try
{
if (Directory.Exists(BaseTestFileFolderPath))
{
Directory.Delete(BaseTestFileFolderPath, true);
Console.WriteLine($"Cleaned up old test directory: {BaseTestFileFolderPath}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Error during cleanup: {ex.Message}");
}
try
{
Directory.CreateDirectory(BaseTestFileFolderPath);
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Error during cleanup create folder: {ex.Message}");
}
}
/// <summary>
/// Copy test files and folders from source paths to destination paths
/// </summary>
private static void CopyTestFilesToDestination()
{
try
{
for (int i = 0; i < OriginalTestFilePaths.Length && i < TestFilesAndFoldersArray.Count; i++)
{
var sourcePath = Path.GetFullPath(OriginalTestFilePaths[i]);
var destinationPath = TestFilesAndFoldersArray[i];
var destinationDir = Path.GetDirectoryName(destinationPath);
if (destinationDir != null && !Directory.Exists(destinationDir))
{
Directory.CreateDirectory(destinationDir);
}
if (Directory.Exists(sourcePath))
{
CopyDirectory(sourcePath, destinationPath);
Console.WriteLine($"Copied directory from {sourcePath} to {destinationPath}");
}
else if (File.Exists(sourcePath))
{
File.Copy(sourcePath, destinationPath, true);
Console.WriteLine($"Copied file from {sourcePath} to {destinationPath}");
}
else
{
Console.WriteLine($"Warning: Source path does not exist: {sourcePath}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error during file copy operation: {ex.Message}");
throw;
}
}
/// <summary>
/// Recursively copy a directory and its contents
/// </summary>
/// <param name="sourceDir">Source directory path</param>
/// <param name="destDir">Destination directory path</param>
private static void CopyDirectory(string sourceDir, string destDir)
{
try
{
// Create target directory
if (!Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}
// Copy all files
foreach (var file in Directory.GetFiles(sourceDir))
{
var fileName = Path.GetFileName(file);
var destFile = Path.Combine(destDir, fileName);
File.Copy(file, destFile, true);
}
// Recursively copy all subdirectories
foreach (var dir in Directory.GetDirectories(sourceDir))
{
var dirName = Path.GetFileName(dir);
var destSubDir = Path.Combine(destDir, dirName);
CopyDirectory(dir, destSubDir);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error copying directory from {sourceDir} to {destDir}: {ex.Message}");
throw;
}
}
protected void SetSearchBoxText(string text)
{
Assert.IsTrue(this.Find<TextBox>("Search for").SetText(text, true).Text == text);
}
protected void SetReplaceBoxText(string text)
{
Assert.IsTrue(this.Find<TextBox>("Replace with").SetText(text, true).Text == text);
}
protected void SetRegularExpressionCheckbox(bool flag)
{
Assert.IsTrue(this.Find<CheckBox>("Use regular expressions").SetCheck(flag).IsChecked == flag);
}
protected void SetMatchAllOccurrencesCheckbox(bool flag)
{
Assert.IsTrue(this.Find<CheckBox>("Match all occurrences").SetCheck(flag).IsChecked == flag);
}
protected void SetCaseSensitiveCheckbox(bool flag)
{
Assert.IsTrue(this.Find<CheckBox>("Case sensitive").SetCheck(flag).IsChecked == flag);
}
protected void CheckOriginalOrRenamedCount(int count)
{
Assert.IsTrue(this.Find<TextBlock>($"({count})").Text == $"({count})");
}
}