mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
## Summary of the Pull Request Adds a `PowerRename.UITests.Next` suite powered by winappcli and automates all 18 scenarios from #40663. The suite covers PowerRename settings, search and replace behavior, regular expressions, formatting and filtering options, file-list interactions, and both classic and Windows 11 context-menu workflows. The PR also stabilizes shared `UITestAutomation.Next` runner lifetimes and settings restoration, adds automation IDs for the original and renamed counters, and prepares unsigned CI builds for PowerRename shell testing. CI now signs the sparse context-menu MSIX and the runner/Settings IPC companions with a disposable machine-trusted test identity. ## PR Checklist - [x] Closes: #40663 - [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 - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [x] **New binaries:** Not applicable; no new shipped product binaries are added - [x] JSON for signing: Not applicable - [x] WXS for installer: Not applicable - [x] YML for CI pipeline: The new UI-test project is discovered through the existing `*UITest*.csproj` pipeline flow and is registered in `PowerToys.slnx` - [x] YML for signed pipeline: Not applicable - [x] **Documentation updated:** Not applicable; there are no user-facing behavior or documentation changes ## Detailed Description of the Pull Request / Additional comments ### PowerRename UI tests - Adds a 25-case `PowerRename.UITests.Next` executable covering all 18 checklist items from #40663. - Exercises classic context-menu registration on Windows 10 and Windows 11. - Exercises the signed Windows 11 tier-1 context menu, including icon visibility and real invocation with an Explorer selection. - Covers search/replace preview and application, text formatting, file/folder/subfolder inclusion, filename/extension scope, enumeration, case sensitivity, match-all behavior, regular expressions, file timestamps, Boost syntax, MRU autocomplete, persisted values, and file-list selection/filtering. - Preserves the existing legacy tests. ### Test reliability and automation hooks - Reuses one runner/Settings lifetime across the complete PowerRename suite to avoid repeated cold launches on constrained agents. - Retains and restores global settings for the full class lifetime and verifies that both Settings and the runner remain healthy. - Propagates scope and PowerRename cleanup failures instead of silently leaking process or profile state. - Adds `OriginalCount` and `RenamedCount` automation IDs. These are automation-only metadata and do not change the visible UI. - Uses stable preview samples, exact count targeting, authoritative Explorer selection, readable classic-menu inventories, and live UIA visibility for popup items. ### Unsigned CI build support - Extends the existing sparse-package test signer with required Authenticode companion files. - PowerRename jobs sign `PowerToys.exe` and `PowerToys.Settings.exe` with the same disposable machine-trusted test identity used for sparse MSIX packages. - This preserves Release IPC authentication while allowing Settings module-toggle commands to work on unsigned PR builds. - Windows 11 and ARM64 PowerRename jobs require a validly signed `PowerRenameContextMenuPackage.msix` before tests start. ## Validation Steps Performed ### Builds and discovery - `PowerRename.UITests.Next` Debug x64 build: passed - `PowerRename.UITests.Next` Debug ARM64 cross-build: passed - `PowerRenameUI` Release x64 build: passed - `UITestAutomation.Next.UnitTests` Debug x64 build: passed - `UITestAutomation.Next.UnitTests`: 16/16 passed - Microsoft.Testing.Platform discovery: 25 unique PowerRename test cases ### Local VM matrix All runs used the complete unfiltered `TestCategory=PowerRename` suite and restored the standard user's settings file byte-for-byte. | Guest | Profile | Result | |---|---|---:| | Windows 10 x64 | Default, 4 vCPU / 8 GB | 25/25 | | Windows 10 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | | Windows 11 x64 | Default, 4 vCPU / 8 GB | 25/25 | | Windows 11 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | ### Azure DevOps UI Test Automation - Final build: [155646235 / 20260824.1](https://dev.azure.com/microsoft/Dart/_build/results?buildId=155646235) - Source revision: `4496683104aea622b30179909bd6e95a17d5500f` - ARM64: 25/25 passed - Windows 10 x64: 25/25 passed - Windows 11 x64: 25/25 passed - Total: 75/75 passed, with zero failed, skipped, not-executed, or unanalyzed results - ARM64 and x64 Release product builds succeeded and published their normal artifacts - Signing steps verified the PowerRename sparse MSIX where applicable and both IPC companion executables on every PowerRename test job <img width="372" height="314" alt="image" src="https://github.com/user-attachments/assets/bc303673-0325-4f85-8d70-ef93110baf5b" />
193 lines
12 KiB
Markdown
193 lines
12 KiB
Markdown
# Shell extensions & the CI signing constraint
|
|
|
|
Read this before writing or verifying UI tests for any module with a **shell extension**
|
|
(context menu, preview handler, thumbnail provider, drag-drop handler). It is the knowledge that is
|
|
*not* obvious from the test framework and caused the most trial-and-error.
|
|
|
|
## The one fact that matters most
|
|
|
|
**CI PR-validation builds are UNSIGNED** (`codeSign:false`). Any test that depends on a
|
|
**sparse-MSIX-packaged** shell extension gets **0% on CI**, because the package cannot register
|
|
(`0x800B0100 TRUST_E_NOSIGNATURE`). A test that passes on your machine (where you self-signed or
|
|
installed a signed build) can therefore fail 100% on CI.
|
|
|
|
## Preferred fix: sign the MSIX on CI and force-trust it
|
|
|
|
The workarounds below (driving the classic menu, launching the exe directly) let a test pass on an
|
|
unsigned build, but they *do not exercise the modern Win11 tier-1 context menu* — the surface real
|
|
users see. The faithful fix is to give CI a genuinely **signed** package plus a **test-only trusted
|
|
root**, so registration succeeds and the tests drive the real end-user workflow. This is legitimate
|
|
because the trust anchor is scoped to the test agent and asserts no security — it just makes Windows
|
|
treat the CI-built package as sideload-installable, exactly like a developer self-signing locally.
|
|
|
|
**Why signing (not Developer Mode) is required.** PowerToys registers each sparse package at
|
|
module-enable time with `PackageManager.AddPackageByUriAsync` (`src/common/utils/package.h`
|
|
`RegisterSparsePackage`) — a *packaged* deployment that demands a signature chaining to a trusted
|
|
root. Developer Mode / register-by-loose-manifest would only help if the product code called
|
|
`AddPackage -Register AppxManifest.xml`, which it does not. So the only route is: **sign the `.msix`
|
|
and trust the signer.**
|
|
|
|
**Mechanism** (all three steps must happen *before* the module is enabled):
|
|
|
|
1. Create a self-signed **code-signing** cert whose subject **exactly equals** the manifest
|
|
`Publisher` — every PowerToys context-menu package and the CmdPal `PowerToysSparse.msix` use
|
|
`CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US`. The private
|
|
key is generated on the agent and never leaves it; nothing is committed.
|
|
2. **Force-trust** it via the **machine** stores: import the public cert into `LocalMachine\Root`
|
|
**and** `LocalMachine\TrustedPeople` (a self-signed leaf is its own root, and AppX sideload
|
|
consults TrustedPeople). These import silently, and the CI test agent is elevated (it installs
|
|
machine-level), so it can write them. Do **not** import into `CurrentUser\Root` — the user Root
|
|
store raises a CryptoAPI consent dialog that fails non-interactively (`UI is not allowed in this
|
|
operation`), even when elevated. `CurrentUser\TrustedPeople` is silent and fine as an extra for
|
|
per-user deployment; a non-elevated run that cannot write `LocalMachine\Root` cannot establish
|
|
machine root trust silently.
|
|
3. `signtool sign /fd SHA256` every sparse `.msix` the product will register.
|
|
|
|
A ready-to-use, publisher-aware implementation ships with this skill:
|
|
[`.pipelines/signSparsePackages.ps1`](../../../../.pipelines/signSparsePackages.ps1). It reads each
|
|
package's manifest publisher, mints/reuses+trusts a matching cert, and signs only packages that are
|
|
not already validly signed (so real framework packages like VCLibs are left alone). Point it at
|
|
whichever tree hosts the packages:
|
|
|
|
```powershell
|
|
# buildNow (run-in-place) — sign the packages in the downloaded build tree:
|
|
.\.pipelines\signSparsePackages.ps1 -PackageRoot "$(Pipeline.Workspace)\$(TestArtifactsName)"
|
|
|
|
# installed (buildNowSlim / official) — sign after install, before the test enables the module.
|
|
# Machine install lands in %ProgramFiles%\PowerToys; per-user install in %LOCALAPPDATA%\PowerToys:
|
|
.\.pipelines\signSparsePackages.ps1 `
|
|
-PackageRoot "$env:ProgramFiles\PowerToys","$env:LOCALAPPDATA\PowerToys" `
|
|
-RequiredPackage 'ImageResizerContextMenuPackage.msix'
|
|
|
|
# local UI-test VM sideload — sign the deployed runtime:
|
|
.\.pipelines\signSparsePackages.ps1 -PackageRoot "C:\PowerToysUiTestRun\PowerToys"
|
|
```
|
|
|
|
**Where it is wired in CI.** This runs in `.pipelines/v2/templates/job-test-project.yml` after the
|
|
download/install steps and before **Run UI Tests**. It recursively searches the run-in-place artifact
|
|
and complete machine/per-user install roots. Windows 11/ARM64 Image Resizer, PowerRename, and
|
|
all-module jobs pass their context-menu MSIX names through `-RequiredPackage`, so missing, unsigned,
|
|
or untrusted setup fails at the prerequisite instead of surfacing later as a product-test failure.
|
|
Jobs that do not exercise either modern context menu keep signing best-effort:
|
|
|
|
PowerRename jobs also pass `PowerToys.exe` and `PowerToys.Settings.exe` through
|
|
`-RequiredAuthenticodeFile` on every platform. Release IPC accepts only a Microsoft-named signer
|
|
anchored in LocalMachine Root; unsigned PR binaries otherwise let the Settings toggle change
|
|
visually while the runner rejects the command as `not-microsoft-signed`. The same disposable-agent
|
|
test identity satisfies that authentication path without weakening the product policy. The job
|
|
records the exact thumbprint in a durable agent-work-folder marker. It processes stale markers before
|
|
signing, then removes the certificate from LocalMachine/User trust stores and CurrentUser\My
|
|
(including its private key) in an `always()` cleanup step. Failed cleanup keeps the marker for the
|
|
next job, so neither interruption nor agent reuse loses the recovery record.
|
|
|
|
```yaml
|
|
- pwsh: |
|
|
$roots = @(
|
|
"$(Pipeline.Workspace)\$(TestArtifactsName)",
|
|
"$env:ProgramFiles\PowerToys",
|
|
"$env:LOCALAPPDATA\PowerToys")
|
|
# Build the platform/module-specific arrays as shown in job-test-project.yml.
|
|
$requiredPackages = @('PowerRenameContextMenuPackage.msix')
|
|
$requiredAuthenticodeFiles = @('PowerToys.exe', 'PowerToys.Settings.exe')
|
|
if ($requiredPackages.Count -gt 0 -or $requiredAuthenticodeFiles.Count -gt 0) {
|
|
$signingArguments = @{
|
|
PackageRoot = $roots
|
|
CertificateMarkerPath = "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt"
|
|
}
|
|
if ($requiredPackages.Count -gt 0) {
|
|
$signingArguments.RequiredPackage = $requiredPackages
|
|
}
|
|
if ($requiredAuthenticodeFiles.Count -gt 0) {
|
|
$signingArguments.RequiredAuthenticodeFile = $requiredAuthenticodeFiles
|
|
}
|
|
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" @signingArguments
|
|
} else {
|
|
try {
|
|
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" `
|
|
-PackageRoot $roots `
|
|
-CertificateMarkerPath "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt"
|
|
}
|
|
catch { Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)" }
|
|
}
|
|
displayName: "Sign sparse MSIX packages (test trust)"
|
|
```
|
|
|
|
**Prerequisite:** `signtool.exe`. The script finds it across PATH, any `Windows Kits` install (all
|
|
versions, plus the App Certification Kit). As a fallback it verifies and freshly extracts the exact
|
|
repository-pinned `Microsoft.Windows.SDK.BuildTools` `.nupkg` from the NuGet cache, or downloads that
|
|
same version when absent. It verifies the NuGet author/repository signatures and refuses to run a
|
|
`signtool.exe` without a valid Microsoft Authenticode signature. An agent without the SDK therefore
|
|
still works without trusting an unversioned or stale extracted tool. Verified end-to-end in a local Win11
|
|
VM — the unsigned package fails `Add-AppxPackage` / `AddPackageByUriAsync` with `0x800B0100`, and
|
|
after `signSparsePackages.ps1` signs it and the cert is force-trusted (`LocalMachine\Root` +
|
|
`TrustedPeople`) the same registration succeeds and the package appears in `Get-AppxPackage`.
|
|
|
|
**Caveat — CmdPal at install time.** The installer's custom action stages/registers
|
|
`PowerToysSparse.msix` *during* install (`installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp`),
|
|
before any test-time signing step runs. Signing after install still covers every module that
|
|
registers at **enable** time (ImageResizer / PowerRename / FileLocksmith / NewPlus). If CmdPal's own
|
|
packaged registration is the thing under test on an installed build, the package must instead be
|
|
signed at **build** time (self-sign in the build stage and publish the public `.cer` for the test
|
|
stage to trust) — the run-in-place `buildNow` path avoids this because nothing registers until the
|
|
test enables it.
|
|
|
|
With this in place a test can drive the modern surface directly on CI. `ModernRegistered()` (below)
|
|
becomes a portability guard for *unsigned* environments rather than a reason to avoid the modern menu.
|
|
|
|
## Two shell-extension tiers
|
|
|
|
| Tier | Mechanism | Signing | Unsigned CI PR build |
|
|
|---|---|---|---|
|
|
| **Modern** (Win11 tier-1 context menu, `IExplorerCommand`) | sparse **MSIX** package | **required** | ❌ cannot register |
|
|
| **Classic** ("Show more options", Win10 default menu, most preview/thumbnail handlers) | **registry COM** (`HKCU\Software\Classes\...\SystemFileAssociations\<ext>\ShellEx\...`) | none | ✅ works |
|
|
|
|
**Rule:** you have two options on an unsigned CI build. **(A, preferred)** sign the modern package
|
|
and force-trust it (see *Preferred fix* above) so the test drives the real Win11 tier-1 menu.
|
|
**(B, fallback)** drive the **signing-free surface** — the classic COM menu, or launch the module exe
|
|
directly with the files (the PowerRename pattern; the exe often has a CLI/`FilesArgument`). If you
|
|
take the fallback, only assert the modern/tier-1 surface when the package is *actually* registered,
|
|
so it runs on signed/official/installed builds only:
|
|
|
|
```csharp
|
|
private static bool ModernRegistered() =>
|
|
new Windows.Management.Deployment.PackageManager()
|
|
.FindPackagesForUser(string.Empty)
|
|
.Any(p => p.Id.Name.Contains("<PackageName>", StringComparison.OrdinalIgnoreCase));
|
|
```
|
|
|
|
Then: `OpenContextMenu(useClassicMenu: !ModernRegistered())`, and gate modern assertions behind
|
|
`if (ModernRegistered())`.
|
|
|
|
## Debug vs Release — why local != CI
|
|
|
|
- The classic registry-COM handler is usually gated `#if defined(ENABLE_REGISTRATION) || defined(NDEBUG)`,
|
|
so it is **compiled out of local Debug builds** and present only in CI **Release** (`NDEBUG`). To
|
|
exercise the classic menu against a **local Debug** runtime, rebuild the extension DLL with
|
|
`ENABLE_REGISTRATION` added to its `<PreprocessorDefinitions>`.
|
|
- The sparse `.msix` in build output is **unsigned**; registering it locally needs a self-signed cert
|
|
whose subject == the package `Publisher` **and** that cert trusted (admin). CI does neither.
|
|
- Both handlers typically **self-gate on the module's enabled flag** at query time (classic
|
|
`QueryContextMenu` returns `E_FAIL`, modern `GetState` returns `ECS_HIDDEN`), so the entry tracks
|
|
the Settings toggle without re-registration.
|
|
- Modules register handlers at **enable time** (runtime), and an **already-running Explorer will not
|
|
surface a freshly-registered handler until the shell restarts** — restart `explorer.exe` once after
|
|
enabling (see PreviewPane / File Explorer add-ons tests).
|
|
|
|
## Reproduce CI's classic scenario on a local (signed) VM
|
|
|
|
1. Rebuild the extension DLL with `ENABLE_REGISTRATION` and deploy it into the guest runtime
|
|
(`C:\PowerToysUiTestRun\PowerToys\WinUI3Apps\`).
|
|
2. Neutralize the sparse package so `enable()` cannot register it: rename its `.msix` and
|
|
`Get-AppxPackage *<Package>* | Remove-AppxPackage -AllUsers`. This mirrors CI's unsigned failure.
|
|
3. The `ModernRegistered()` detection now returns false → the tests drive the classic menu exactly as
|
|
CI does. Use `Invoke-GuestScript.ps1` for the guest-side steps.
|
|
|
|
## Slow-agent / cross-arch robustness
|
|
|
|
Shell-extension tests are especially prone to slow-agent and ARM64-only races that you often cannot
|
|
reproduce on a local VM (even constrained to 1 core, see customization.md — you cannot emulate ARM64
|
|
on an x64 host, so reason from the failure video/screenshot). The robustness patterns — re-select
|
|
before every attempt, retryable transient popups, verify fixtures reached disk, and slow-path
|
|
timeouts — are test design and live with the rest of the Explorer/shell test guidance in
|
|
[ui-tests-migration explorer-shell-tests.md](../../ui-tests-migration/references/explorer-shell-tests.md).
|