Files
PowerToys/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ExtensionTemplateServiceTests.cs
Jiří Polášek 7685cd1226 CmdPal: Fix binary file corruption in Create Extension (#46490)
<!-- 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

This PR fixes a problem with invisible icons in newly create Command
Palette extensions, when created through built-in command:

- Avoids modifying binary files during extension creation from the
template to prevent corruption.
- Refactors template expansion and physical extension creation into a
separate ExtensionTemplateService.
- Adds unit tests.

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

- [x] Closes: #46448
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **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
2026-03-28 16:11:07 -05:00

189 lines
8.3 KiB
C#

// 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.IO.Compression;
using System.Linq;
using System.Text;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class ExtensionTemplateServiceTests
{
private string _templateRoot = null!;
private string _outputRoot = null!;
[TestInitialize]
public void Setup()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"{nameof(ExtensionTemplateServiceTests)}_{Guid.NewGuid():N}");
_templateRoot = Path.Combine(tempRoot, "template");
_outputRoot = Path.Combine(tempRoot, "output");
Directory.CreateDirectory(_templateRoot);
Directory.CreateDirectory(_outputRoot);
}
[TestCleanup]
public void Cleanup()
{
var tempRoot = Directory.GetParent(_templateRoot)?.FullName;
if (!string.IsNullOrEmpty(tempRoot) && Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
[TestMethod]
public void CreateExtension_BuildsExtensionFromTemplateArchive()
{
// Arrange
var archiveRoot = Path.Combine(_templateRoot, "archive");
var templateProjectRoot = Path.Combine(archiveRoot, "TemplateCmdPalExtension", "TemplateCmdPalExtension");
Directory.CreateDirectory(Path.Combine(templateProjectRoot, "Assets"));
File.WriteAllText(
Path.Combine(templateProjectRoot, "Program.cs"),
"TemplateCmdPalExtension TemplateDisplayName FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF");
File.WriteAllBytes(Path.Combine(templateProjectRoot, "Assets", "Logo.png"), [0x89, 0x50, 0x4E, 0x47]);
var templateZipPath = Path.Combine(_templateRoot, "template.zip");
ZipFile.CreateFromDirectory(archiveRoot, templateZipPath);
var service = new ExtensionTemplateService(templateZipPath);
// Act
service.CreateExtension("MyExtension", "My Display Name", _outputRoot);
// Assert
var programFile = Path.Combine(_outputRoot, "MyExtension", "MyExtension", "Program.cs");
var imageFile = Path.Combine(_outputRoot, "MyExtension", "MyExtension", "Assets", "Logo.png");
Assert.IsTrue(File.Exists(programFile));
Assert.IsTrue(File.Exists(imageFile));
StringAssert.Contains(File.ReadAllText(programFile), "MyExtension");
StringAssert.Contains(File.ReadAllText(programFile), "My Display Name");
Assert.IsFalse(File.ReadAllText(programFile).Contains("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", StringComparison.Ordinal));
CollectionAssert.AreEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, File.ReadAllBytes(imageFile));
}
[TestMethod]
public void CopyTemplateFile_RewritesTextFiles()
{
// Arrange
var sourceFile = Path.Combine(_templateRoot, "TemplateCmdPalExtension", "Program.cs");
Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!);
File.WriteAllText(sourceFile, "TemplateCmdPalExtension TemplateDisplayName FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF");
// Act
ExtensionTemplateService.CopyTemplateFile(_templateRoot, sourceFile, _outputRoot, "MyExtension", "My Display Name", "11111111-1111-1111-1111-111111111111");
// Assert
var outputFile = Path.Combine(_outputRoot, "MyExtension", "Program.cs");
Assert.IsTrue(File.Exists(outputFile));
Assert.AreEqual(
"MyExtension My Display Name 11111111-1111-1111-1111-111111111111",
File.ReadAllText(outputFile));
}
[TestMethod]
public void CopyTemplateFile_CopiesUnchangedTextFilesVerbatim()
{
// Arrange
var sourceFile = Path.Combine(_templateRoot, "TemplateCmdPalExtension", "Properties", "launchSettings.json");
Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!);
var sourceBytes = Encoding.UTF8.GetPreamble()
.Concat(Encoding.UTF8.GetBytes("{\"profiles\":{\"CmdPal\":{}}}"))
.ToArray();
File.WriteAllBytes(sourceFile, sourceBytes);
// Act
ExtensionTemplateService.CopyTemplateFile(_templateRoot, sourceFile, _outputRoot, "MyExtension", "My Display Name", "11111111-1111-1111-1111-111111111111");
// Assert
var outputFile = Path.Combine(_outputRoot, "MyExtension", "Properties", "launchSettings.json");
Assert.IsTrue(File.Exists(outputFile));
CollectionAssert.AreEqual(sourceBytes, File.ReadAllBytes(outputFile));
}
[TestMethod]
public void CopyTemplateFile_CopiesBinaryFilesWithoutRewritingContents()
{
// Arrange
var sourceFile = Path.Combine(_templateRoot, "TemplateCmdPalExtension", "Assets", "Logo.png");
Directory.CreateDirectory(Path.GetDirectoryName(sourceFile)!);
var binaryContent = new byte[]
{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00,
};
var embeddedText = Encoding.UTF8.GetBytes("TemplateCmdPalExtension TemplateDisplayName");
File.WriteAllBytes(sourceFile, [.. binaryContent, .. embeddedText]);
// Act
ExtensionTemplateService.CopyTemplateFile(_templateRoot, sourceFile, _outputRoot, "MyExtension", "My Display Name", "11111111-1111-1111-1111-111111111111");
// Assert
var outputFile = Path.Combine(_outputRoot, "MyExtension", "Assets", "Logo.png");
Assert.IsTrue(File.Exists(outputFile));
CollectionAssert.AreEqual(File.ReadAllBytes(sourceFile), File.ReadAllBytes(outputFile));
}
[TestMethod]
public void TemplateFileHandling_ThrowsForUnknownExtension()
{
var ex = Assert.ThrowsException<InvalidOperationException>(() => ExtensionTemplateService.GetTemplateFileHandling("template.svg"));
StringAssert.Contains(ex.Message, ".svg");
}
[TestMethod]
public void TemplateExtensionCategories_AreDisjointAndCoverTemplateZip()
{
var replaceTokens = ExtensionTemplateService.ReplaceTokensTemplateExtensions.ToHashSet(StringComparer.OrdinalIgnoreCase);
var copyAsIs = ExtensionTemplateService.CopyAsIsTemplateExtensions.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allAccountedFor = replaceTokens.Concat(copyAsIs).ToHashSet(StringComparer.OrdinalIgnoreCase);
var templateZipExtensions = GetTemplateZipExtensions();
CollectionAssert.AreEqual(Array.Empty<string>(), replaceTokens.Intersect(copyAsIs, StringComparer.OrdinalIgnoreCase).ToArray());
CollectionAssert.AreEquivalent(templateZipExtensions.OrderBy(x => x).ToArray(), allAccountedFor.OrderBy(x => x).ToArray());
}
[TestMethod]
public void TemplateZipFiles_AllUseKnownHandling()
{
using var archive = ZipFile.OpenRead(TemplateZipPath);
var replaceTokens = ExtensionTemplateService.ReplaceTokensTemplateExtensions.ToHashSet(StringComparer.OrdinalIgnoreCase);
var copyAsIs = ExtensionTemplateService.CopyAsIsTemplateExtensions.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => !string.IsNullOrEmpty(Path.GetExtension(entry.FullName))))
{
var extension = Path.GetExtension(entry.FullName);
var expectedHandling = replaceTokens.Contains(extension)
? ExtensionTemplateService.TemplateFileHandling.ReplaceTokens
: ExtensionTemplateService.TemplateFileHandling.CopyAsIs;
Assert.AreEqual(expectedHandling, ExtensionTemplateService.GetTemplateFileHandling(entry.FullName), entry.FullName);
Assert.IsTrue(replaceTokens.Contains(extension) || copyAsIs.Contains(extension), entry.FullName);
}
}
private static string TemplateZipPath => Path.Combine(AppContext.BaseDirectory, "Assets", "template.zip");
private static HashSet<string> GetTemplateZipExtensions()
{
using var archive = ZipFile.OpenRead(TemplateZipPath);
return archive.Entries
.Where(entry => !string.IsNullOrEmpty(Path.GetExtension(entry.FullName)))
.Select(entry => Path.GetExtension(entry.FullName))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
}