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

12 KiB

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

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

  - 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:

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.