Files
PowerToys/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md
Gleb Khmyznikov bea1b8e247 [UITests][PowerRename] Migrate to new .Next and add more UI tests (#50096)
## 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"
/>
2026-08-26 00:26:10 +02:00

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).