diff --git a/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md b/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md index cc378d1413..9eff2a3dad 100644 --- a/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md +++ b/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md @@ -65,10 +65,20 @@ whichever tree hosts the packages: **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 and all-module jobs pass -`-RequiredPackage ImageResizerContextMenuPackage.msix`, so missing, unsigned, or untrusted setup fails -at the prerequisite instead of surfacing later as a product-test failure. Jobs that do not exercise -Image Resizer keep signing best-effort because their suites can guard unavailable modern packages: +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: | @@ -76,20 +86,38 @@ Image Resizer keep signing best-effort because their suites can guard unavailabl "$(Pipeline.Workspace)\$(TestArtifactsName)", "$env:ProgramFiles\PowerToys", "$env:LOCALAPPDATA\PowerToys") - if ($requiresImageResizer) { - & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" ` - -PackageRoot $roots -RequiredPackage 'ImageResizerContextMenuPackage.msix' + # 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 } + 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), and a restored SDK BuildTools NuGet package; as a last -resort it fetches the public `Microsoft.Windows.SDK.BuildTools` package from nuget.org, so an agent -without the SDK still works given outbound access. Verified end-to-end in a local Win11 +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`. diff --git a/.github/skills/ui-tests-pipeline-ci/SKILL.md b/.github/skills/ui-tests-pipeline-ci/SKILL.md index 5b0cfe0c36..c885286c64 100644 --- a/.github/skills/ui-tests-pipeline-ci/SKILL.md +++ b/.github/skills/ui-tests-pipeline-ci/SKILL.md @@ -1,6 +1,6 @@ --- name: ui-tests-pipeline-ci -description: "Microsoft FTE-only workflow for queueing, monitoring, and stabilizing PowerToys UI Test Automation runs through an existing Azure CLI session and Azure DevOps REST APIs. Use after local default and constrained VM suites pass, when asked to run UITests CI, monitor a pipeline without repeated authentication prompts, reuse a successful product build with specificBuildId, diagnose CI-only UI test failures, download recordings/artifacts, or manage the three-run stabilization limit. Keywords: FTE, az, Azure CLI, Azure DevOps, pipeline, UI Test Automation, UITests CI, buildNow, specificBuildId, uiTestModules, failed test video, CI flake." +description: "Microsoft FTE-only workflow for validating setup, queueing, monitoring, and stabilizing PowerToys UI Test Automation through an existing Azure CLI session and Azure DevOps REST APIs. Use after local VM suites pass, when asked to run UITests CI, perform a setup preflight/readiness check, diagnose repeated az login prompts or 401/403 permission failures, reuse a successful build, inspect recordings/artifacts, or manage the three-run limit. Keywords: FTE, az, Azure CLI, Azure DevOps, pipeline, UI Test Automation, UITests CI, buildNow, specificBuildId, uiTestModules, CI flake." license: MIT --- @@ -21,6 +21,7 @@ discovery, preview, queueing, status, timelines, logs, tests, artifacts, and res Use this skill when an authorized Microsoft FTE asks to: - Queue PowerToys UITests in the internal `UI Test Automation` pipeline. +- Validate Azure CLI and Azure DevOps readiness before queueing or after a `401`/`403` response. - Monitor a UITests pipeline run or summarize its stages, tests, and artifacts. - Iterate on a failure that passed the complete local VM matrix. - Reuse a prior successful product build while rebuilding only one or more UITest projects. @@ -32,22 +33,26 @@ Do not use this skill for local execution. Complete ## Non-negotiable gates -1. **Local first.** Do not queue CI until all required local runs are green, including full suites on +1. **Setup preflight first.** Before the first Azure operation in a session, run + [Test-AzureDevOpsSetup.ps1](./scripts/Test-AzureDevOpsSetup.ps1) and require `Ready=true` with + every required check `PASS`. It performs reads and a non-mutating pipeline preview only. Re-run it + after account changes or any `401`/`403` response. +2. **Local first.** Do not queue CI until all required local runs are green, including full suites on the default and `Constrained` profiles for Windows 10 and Windows 11, plus the applicable architecture builds/guests required by `ui-tests-local-vm`. -2. **Pushed revision.** Queue only a pushed branch. Record its exact commit and verify the queued +3. **Pushed revision.** Queue only a pushed branch. Record its exact commit and verify the queued run's `sourceVersion` matches it. -3. **One run per branch.** Before queueing, discover active runs for `UI Test Automation`. Wait for +4. **One run per branch.** Before queueing, discover active runs for `UI Test Automation`. Wait for or cancel a relevant superseded run on the target branch; runs on other branches may continue in parallel. Never cancel another branch's unrelated run. -4. **Always scope modules.** `uiTestModules` must be non-empty and contain the exact current UITest +5. **Always scope modules.** `uiTestModules` must be non-empty and contain the exact current UITest project stem, for example `[FancyZonesEditor.UITests.Next]`. -5. **Three-run ceiling.** A CI stabilization sequence may queue at most three runs total. Keep an +6. **Three-run ceiling.** A CI stabilization sequence may queue at most three runs total. Keep an attempt ledger. If run 3 is not green, stop and ask the user for assistance. Also stop when three consecutive runs show no stabilization progress. -6. **Evidence before edits.** Read the failed result, logs, screenshot, and recording before forming +7. **Evidence before edits.** Read the failed result, logs, screenshot, and recording before forming a fix hypothesis. Preserve assertions and classify infrastructure failures separately. -7. **Tracked runs remain unfinished work.** After queueing, persist the build ID, branch, source SHA, +8. **Tracked runs remain unfinished work.** After queueing, persist the build ID, branch, source SHA, attempt number, and parameters in session/task state. Do not mark the task complete or claim a terminal result while that build is nonterminal. If no authenticated completion waiter exists, arm the one-hour scheduled continuation in the agentic loop rather than relying on a passive @@ -62,6 +67,8 @@ Do not use this skill for local execution. Complete | Project | `Dart` | | Pipeline name | `UI Test Automation` | | Current known definition ID | `161438` (discover by name each session; do not blindly hardcode) | +| Azure DevOps token resource | `499b84ac-1321-427f-aa17-267ca6975798` | +| Required setup check | `scripts/Test-AzureDevOpsSetup.ps1` | | Platforms | `arm64`, `x64` | | Default booleans | `enableMsBuildCaching=false`, `useVSPreview=false`, `useLatestWebView2=false` | @@ -70,7 +77,8 @@ Do not use this skill for local execution. Complete Read and execute [references/agentic-loop.md](./references/agentic-loop.md) from top to bottom. It contains: -- Prompt-free Azure CLI session validation and the bundled +- The required prompt-free + [setup preflight](./scripts/Test-AzureDevOpsSetup.ps1) and bundled [REST helper](./scripts/AzureDevOps.ps1). - Local-signoff and active-run preflight. - `buildNow` versus `specificBuildId` decision rules. diff --git a/.github/skills/ui-tests-pipeline-ci/references/agentic-loop.md b/.github/skills/ui-tests-pipeline-ci/references/agentic-loop.md index d338fa1643..84eb8132d8 100644 --- a/.github/skills/ui-tests-pipeline-ci/references/agentic-loop.md +++ b/.github/skills/ui-tests-pipeline-ci/references/agentic-loop.md @@ -11,11 +11,46 @@ Azure DevOps REST APIs. This avoids per-call authentication prompts and works fo builds, timelines, logs, preview/queue, stage retry/cancel, test results, artifacts, and result attachments. -Dot-source the bundled helper and validate the session once: +### Required one-command readiness gate + +Before the first Azure operation in each agent session, run the preflight in a fresh PowerShell 7 +process: + +```pwsh +pwsh -NoLogo -NoProfile -File ` + .github\skills\ui-tests-pipeline-ci\scripts\Test-AzureDevOpsSetup.ps1 +``` + +Do not proceed unless it exits `0`, reports `Ready: true`, and every required check is `PASS`. The +default probe uses `refs/heads/main`, module `FancyZones.UITests.Next`, and dynamically selects one +of the ten newest completed pipeline builds for build/log/artifact checks plus the first test-bearing +build in that set for Azure Test checks. The JSON reports these separately as `ProbeBuildId` and +`ProbeTestBuildId`. It creates no build and changes no Azure or repository state. + +Use explicit probe inputs when diagnosing a particular branch or known build: + +```pwsh +pwsh -NoLogo -NoProfile -File ` + .github\skills\ui-tests-pipeline-ci\scripts\Test-AzureDevOpsSetup.ps1 ` + -ProbeBranch refs/heads/ ` + -ProbeModule ` + -ProbeBuildId +``` + +For the check inventory, precise capability claims, and first-time remediation, read +[setup-preflight.md](setup-preflight.md) only when setup fails or the user asks about readiness. +The preflight deliberately performs no mutation. + +The agent never starts an interactive sign-in or installs tools. If preflight fails, stop and report +the exact failed check. The user performs any required setup outside the agent, then the agent reruns +the same preflight. Never pass credentials through chat or run `az login` from the agent. + +### Use the REST helper after preflight + +Dot-source the bundled helper for actual work only after preflight passes: ```pwsh . .\.github\skills\ui-tests-pipeline-ci\scripts\AzureDevOps.ps1 -Test-AzDevOpsSession ``` The helper obtains a token for Azure DevOps resource @@ -26,8 +61,8 @@ after every request. Repeated reads and actual pipeline queueing were verified w Never run `az login` through an agent, request credentials, print or persist a token/header, enable command tracing around authentication, or commit downloaded internal evidence. If -`Test-AzDevOpsSession` fails, ask the user to authenticate outside the agent and stop with an access -blocker. Do not fall back to another transport. +the preflight fails, ask the user to resolve its exact failed check and stop with an access blocker. +Do not fall back to another transport. `Invoke-AzDevOpsRest` accepts a project-relative REST path and returns `{ Body, Headers }`: diff --git a/.github/skills/ui-tests-pipeline-ci/references/setup-preflight.md b/.github/skills/ui-tests-pipeline-ci/references/setup-preflight.md new file mode 100644 index 0000000000..45d3f327a0 --- /dev/null +++ b/.github/skills/ui-tests-pipeline-ci/references/setup-preflight.md @@ -0,0 +1,54 @@ +# Azure DevOps setup preflight + +Read this reference only when `Test-AzureDevOpsSetup.ps1` fails or the user asks what the readiness +check proves. The normal CI workflow needs only the invocation and pass criterion in +[agentic-loop.md](agentic-loop.md). + +## Capability checks + +| Check | What a pass proves | +|---|---| +| `PowerShell7` | The script is running on supported PowerShell 7+ semantics. | +| `AzureCLI` | `az` is installed, executable, and reports a parseable version. | +| `AzureDevOpsExtension` | The `azure-devops` CLI extension is installed, so artifact-download commands are available. | +| `CachedSignInAndToken` | The existing account can mint an Azure DevOps resource token without prompting. | +| `ProjectRead` | The identity can access organization `microsoft` and project `Dart`. | +| `PipelineDefinitionRead` | The enabled `UI Test Automation` definition is visible and uniquely resolved. | +| `BuildRead`, `TimelineRead`, `BuildLogsRead`, `ArtifactsRead` | Build diagnostics and pipeline artifacts are readable. | +| `TestRunsRead`, `TestResultsRead`, `TestAttachmentsRead` | Azure Test evidence endpoints are readable. An attachment count of zero is still a successful permission check. | +| `PipelinePreview` | The Run Pipeline API accepts the identity, branch, parameters, and template expansion. `id=-1` proves no build was created. | +| `RepeatedPromptFreeRead` | A second token-backed call completes without another authentication prompt. | + +The preflight deliberately does **not** create a run, cancel a build, or retry/cancel a stage. Those +mutations consume resources or change tracked work and are verified only when the user's request +authorizes the real operation. After every authorized write, re-read the build/timeline and require +the expected state transition; a successful HTTP response alone is not proof that a delayed stage +retry materialized. + +Do not use `az devops security permission show` or Azure DevOps Graph-user enumeration as the setup +gate. Resolving the current Graph descriptor can require the unrelated `ReadExtended Users` +permission, which many valid pipeline users do not have. A failure there does not mean pipeline +access is missing. The endpoint capability checks above are the authoritative, least-privilege +readiness proof. + +## Failure remediation + +The agent never starts an interactive sign-in or installs tools. If preflight fails, stop and report +the exact failed check. The user performs any required setup outside the agent, then the agent reruns +the same preflight. + +| Failed check | Required remediation | +|---|---| +| `PowerShell7` | Install/use PowerShell 7 and invoke the script with `pwsh`, not Windows PowerShell. | +| `AzureCLI` | Install Azure CLI and open a new shell where `az version` succeeds. | +| `AzureDevOpsExtension` | User runs `az extension add --name azure-devops`, then reruns preflight. | +| `CachedSignInAndToken` | User signs into the Microsoft tenant with Azure CLI outside the agent. Never pass credentials through chat or run `az login` from the agent. | +| `ProjectRead` | Confirm the signed-in identity is a Microsoft FTE with access to `microsoft/Dart`. A valid token alone is insufficient. | +| `PipelineDefinitionRead` | Confirm project access and that `UI Test Automation` still exists and is enabled. | +| Build/log/artifact/test read | Request the missing Azure DevOps project/build/test permission; do not weaken evidence requirements. | +| `PipelinePreview` | Confirm the branch exists, the probe module is valid, and the identity can use/queue pipeline `UI Test Automation`. No run was created. | + +No `az devops configure` defaults, PAT, `AZURE_DEVOPS_EXT_PAT`, service connection secret, or local +credential file is required. Every helper call supplies organization/project explicitly and obtains +the Azure DevOps token from the existing Azure CLI cache. Rerun preflight after switching accounts, +after token-cache changes, or immediately after any `401`/`403` response. diff --git a/.github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1 b/.github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1 new file mode 100644 index 0000000000..08718cf87e --- /dev/null +++ b/.github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1 @@ -0,0 +1,498 @@ +# 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. + +#requires -Version 7.0 + +<# +.SYNOPSIS +Validates the prompt-free Azure CLI and Azure DevOps capabilities required by the UI-test pipeline skill. + +.DESCRIPTION +Performs only read operations and a pipeline preview. A preview expands the selected YAML without +creating a build. The script never initiates sign-in, prints a token, or mutates Azure DevOps. + +.PARAMETER Organization +Azure DevOps organization name. Defaults to the internal `microsoft` organization. + +.PARAMETER Project +Azure DevOps project name. Defaults to `Dart`. + +.PARAMETER PipelineName +Enabled pipeline definition to discover. Defaults to `UI Test Automation`. + +.PARAMETER ProbeBranch +Existing full Git ref used for the non-mutating preview, for example `refs/heads/main`. + +.PARAMETER ProbeModule +One existing UITest project stem used to expand the preview, without brackets. + +.PARAMETER ProbeBuildId +Optional completed build from the discovered pipeline. When omitted, the preflight inspects the ten +newest completed builds and chooses build/test probes automatically. + +.EXAMPLE +pwsh .github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1 + +.EXAMPLE +pwsh .github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1 ` + -ProbeBranch refs/heads/my-branch ` + -ProbeModule MyModule.UITests +#> + +[CmdletBinding()] +param( + [string] $Organization = 'microsoft', + + [string] $Project = 'Dart', + + [string] $PipelineName = 'UI Test Automation', + + [string] $ProbeBranch = 'refs/heads/main', + + [string] $ProbeModule = 'FancyZones.UITests.Next', + + [long] $ProbeBuildId = 0 +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'AzureDevOps.ps1') + +$checks = [Collections.Generic.List[object]]::new() + +function Add-SetupCheck +{ + param( + [string] $Name, + [ValidateSet('PASS', 'FAIL', 'SKIP')] + [string] $Status, + [bool] $Required, + [string] $Detail + ) + + $checks.Add([pscustomobject]@{ + Name = $Name + Status = $Status + Required = $Required + Detail = $Detail + }) +} + +function Get-FixedError +{ + param( + [Management.Automation.ErrorRecord] $ErrorRecord, + [string] $Fallback + ) + + if ($ErrorRecord.Exception -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.Exception.Message)) + { + return $ErrorRecord.Exception.Message + } + + return $Fallback +} + +$pipelineId = 0 +$probeBuild = $null +$probeTestBuild = $null +$buildCandidates = @() +$probeTestRuns = @() + +try +{ + if ($PSVersionTable.PSVersion.Major -lt 7) + { + throw "PowerShell 7 or newer is required; found $($PSVersionTable.PSVersion)." + } + + Add-SetupCheck 'PowerShell7' 'PASS' $true $PSVersionTable.PSVersion.ToString() +} +catch +{ + Add-SetupCheck 'PowerShell7' 'FAIL' $true (Get-FixedError $_ 'PowerShell 7 validation failed.') +} + +try +{ + $azCommand = Get-Command az -ErrorAction Stop + $versionOutput = & az version --output json --only-show-errors 2>&1 + if ($LASTEXITCODE -ne 0) + { + throw 'Azure CLI version query failed.' + } + + $versionObject = ($versionOutput | Out-String) | ConvertFrom-Json + $version = [string]$versionObject.PSObject.Properties['azure-cli'].Value + if ([string]::IsNullOrWhiteSpace($version)) + { + throw 'Azure CLI did not report its version.' + } + + Add-SetupCheck 'AzureCLI' 'PASS' $true "$($azCommand.Source) v$version" +} +catch +{ + Add-SetupCheck 'AzureCLI' 'FAIL' $true (Get-FixedError $_ 'Azure CLI is unavailable.') +} + +try +{ + $extensionOutput = & az extension show ` + --name azure-devops ` + --query '{name:name,version:version}' ` + --output json ` + --only-show-errors 2>&1 + if ($LASTEXITCODE -ne 0) + { + throw 'The Azure DevOps CLI extension is not installed.' + } + + $extension = ($extensionOutput | Out-String) | ConvertFrom-Json + Add-SetupCheck 'AzureDevOpsExtension' 'PASS' $true "$($extension.name) v$($extension.version)" +} +catch +{ + Add-SetupCheck 'AzureDevOpsExtension' 'FAIL' $true (Get-FixedError $_ 'The Azure DevOps CLI extension is unavailable.') +} + +try +{ + $session = Test-AzDevOpsSession + Add-SetupCheck ` + 'CachedSignInAndToken' ` + 'PASS' ` + $true ` + "tenant=$($session.TenantId); userType=$($session.UserType); expires=$($session.TokenExpiresOn)" +} +catch +{ + Add-SetupCheck 'CachedSignInAndToken' 'FAIL' $true (Get-FixedError $_ 'Azure CLI sign-in validation failed.') +} + +try +{ + $projectUri = "https://dev.azure.com/$Organization/_apis/projects/$([Uri]::EscapeDataString($Project))?api-version=7.1" + $projectInfo = (Invoke-AzDevOpsRest -Uri $projectUri -Organization $Organization -Project $Project).Body + if (-not $projectInfo.id -or $projectInfo.state -ne 'wellFormed') + { + throw "Project '$Project' is unavailable or not ready." + } + + Add-SetupCheck 'ProjectRead' 'PASS' $true "project=$($projectInfo.name); state=$($projectInfo.state)" +} +catch +{ + Add-SetupCheck 'ProjectRead' 'FAIL' $true (Get-FixedError $_ "Cannot read $Organization/$Project.") +} + +try +{ + $encodedPipelineName = [Uri]::EscapeDataString($PipelineName) + $definitions = @((Invoke-AzDevOpsRest ` + -Uri "_apis/build/definitions?name=$encodedPipelineName&api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body.value | + Where-Object queueStatus -EQ 'enabled') + if ($definitions.Count -ne 1) + { + throw "Expected one enabled '$PipelineName' definition; found $($definitions.Count)." + } + + $pipelineId = [int]$definitions[0].id + Add-SetupCheck ` + 'PipelineDefinitionRead' ` + 'PASS' ` + $true ` + "id=$pipelineId; revision=$($definitions[0].revision)" +} +catch +{ + Add-SetupCheck 'PipelineDefinitionRead' 'FAIL' $true (Get-FixedError $_ 'Pipeline discovery failed.') +} + +if ($pipelineId -ne 0) +{ + try + { + if ($ProbeBuildId -ne 0) + { + $probeBuild = (Invoke-AzDevOpsRest ` + -Uri "_apis/build/builds/${ProbeBuildId}?api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + if ([int]$probeBuild.definition.id -ne $pipelineId) + { + throw "Probe build $ProbeBuildId does not belong to pipeline $pipelineId." + } + + if ($probeBuild.status -ne 'completed') + { + throw "Probe build $ProbeBuildId is '$($probeBuild.status)'; use a completed build." + } + + $buildCandidates = @($probeBuild) + } + else + { + $buildPage = (Invoke-AzDevOpsRest ` + -Uri "_apis/build/builds?definitions=$pipelineId&statusFilter=completed&queryOrder=queueTimeDescending&%24top=10&api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + $buildCandidates = @($buildPage.value) + if ($buildCandidates.Count -eq 0) + { + throw "No completed build was found among the 10 newest runs for pipeline $pipelineId." + } + + $probeBuild = $buildCandidates[0] + } + + Add-SetupCheck ` + 'BuildRead' ` + 'PASS' ` + $true ` + "id=$($probeBuild.id); status=$($probeBuild.status); result=$($probeBuild.result)" + } + catch + { + Add-SetupCheck 'BuildRead' 'FAIL' $true (Get-FixedError $_ 'Completed build discovery failed.') + } +} +else +{ + Add-SetupCheck 'BuildRead' 'SKIP' $true 'Pipeline definition was not resolved.' +} + +if ($null -ne $probeBuild) +{ + try + { + $timeline = (Invoke-AzDevOpsRest ` + -Uri "_apis/build/builds/$($probeBuild.id)/timeline?api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + Add-SetupCheck 'TimelineRead' 'PASS' $true "records=$(@($timeline.records).Count)" + } + catch + { + Add-SetupCheck 'TimelineRead' 'FAIL' $true (Get-FixedError $_ 'Build timeline read failed.') + } + + try + { + $logs = (Invoke-AzDevOpsRest ` + -Uri "_apis/build/builds/$($probeBuild.id)/logs?api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + Add-SetupCheck 'BuildLogsRead' 'PASS' $true "logs=$(@($logs.value).Count)" + } + catch + { + Add-SetupCheck 'BuildLogsRead' 'FAIL' $true (Get-FixedError $_ 'Build log read failed.') + } + + try + { + $artifacts = (Invoke-AzDevOpsRest ` + -Uri "_apis/build/builds/$($probeBuild.id)/artifacts?api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + Add-SetupCheck 'ArtifactsRead' 'PASS' $true "artifacts=$(@($artifacts.value).Count)" + } + catch + { + Add-SetupCheck 'ArtifactsRead' 'FAIL' $true (Get-FixedError $_ 'Pipeline artifact read failed.') + } + + try + { + foreach ($candidate in $buildCandidates) + { + $candidateBuildUri = [Uri]::EscapeDataString("vstfs:///Build/Build/$($candidate.id)") + $candidateRuns = @((Invoke-AzDevOpsRest ` + -Uri "_apis/test/runs?buildUri=$candidateBuildUri&api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body.value) + if ($candidateRuns.Count -gt 0) + { + $probeTestBuild = $candidate + $probeTestRuns = $candidateRuns + break + } + } + + if ($probeTestRuns.Count -eq 0) + { + throw "No Azure Test run was found for the selected probe build set. Pass -ProbeBuildId with a known test-bearing completed build." + } + + Add-SetupCheck ` + 'TestRunsRead' ` + 'PASS' ` + $true ` + "build=$($probeTestBuild.id); runs=$($probeTestRuns.Count)" + + $probeRunId = [long]$probeTestRuns[0].id + $resultPage = (Invoke-AzDevOpsRest ` + -Uri "_apis/test/Runs/${probeRunId}/results?%24top=1&%24skip=0&api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + $probeResults = @($resultPage.value) + if ($probeResults.Count -eq 0) + { + throw "Probe test run $probeRunId has no results." + } + + Add-SetupCheck 'TestResultsRead' 'PASS' $true "run=$probeRunId; pageResults=$($probeResults.Count)" + + $probeResultId = [long]$probeResults[0].id + $attachments = (Invoke-AzDevOpsRest ` + -Uri "_apis/test/Runs/${probeRunId}/Results/${probeResultId}/attachments?api-version=7.1-preview.1" ` + -Organization $Organization ` + -Project $Project).Body + Add-SetupCheck ` + 'TestAttachmentsRead' ` + 'PASS' ` + $true ` + "run=$probeRunId; result=$probeResultId; attachments=$(@($attachments.value).Count)" + } + catch + { + Add-SetupCheck 'AzureTestRead' 'FAIL' $true (Get-FixedError $_ 'Azure Test read failed.') + } +} +else +{ + Add-SetupCheck 'BuildDependentReads' 'SKIP' $true 'No probe build was resolved.' +} + +if ($pipelineId -ne 0) +{ + try + { + if (-not $ProbeBranch.StartsWith('refs/heads/', [StringComparison]::Ordinal)) + { + throw "ProbeBranch must be a full refs/heads/... ref; received '$ProbeBranch'." + } + + if ([string]::IsNullOrWhiteSpace($ProbeModule)) + { + throw 'ProbeModule cannot be empty.' + } + + $previewRequest = @{ + previewRun = $true + resources = @{ repositories = @{ self = @{ refName = $ProbeBranch } } } + templateParameters = @{ + buildPlatforms = '- x64' + enableMsBuildCaching = 'false' + useVSPreview = 'false' + useLatestWebView2 = 'false' + buildSource = 'buildNow' + specificBuildId = 'xxxx' + uiTestModules = "[$ProbeModule]" + } + } + $preview = (Invoke-AzDevOpsRest ` + -Uri "_apis/pipelines/${pipelineId}/runs?api-version=7.1-preview.1" ` + -Method Post ` + -Body $previewRequest ` + -Organization $Organization ` + -Project $Project).Body + if ([long]$preview.id -ne -1 -or [string]::IsNullOrWhiteSpace([string]$preview.finalYaml)) + { + throw 'Pipeline preview did not return id=-1 and expanded YAML.' + } + + $stages = @([regex]::Matches($preview.finalYaml, '(?m)^- stage: (.+)$') | + ForEach-Object { $_.Groups[1].Value.Trim() }) + $expectedStages = @('Build_x64', 'Test_x64Win10_FullBuild', 'Test_x64Win11_FullBuild') + $missingStages = @($expectedStages | Where-Object { $_ -notin $stages }) + if ($missingStages.Count -ne 0) + { + throw "Pipeline preview omitted required stages: $($missingStages -join ', '). Expanded stages: $($stages -join ', ')." + } + + $moduleAssignments = @($preview.finalYaml -split "`n" | + Where-Object { $_ -match '\$modulesRaw\s*=' } | + ForEach-Object { $_.Trim() }) + $expectedModuleAssignment = "`$modulesRaw = '$ProbeModule'" + if ($moduleAssignments.Count -eq 0 -or + @($moduleAssignments | Where-Object { $_ -ne $expectedModuleAssignment }).Count -ne 0) + { + throw "Pipeline preview did not resolve only module '$ProbeModule'." + } + + Add-SetupCheck ` + 'PipelinePreview' ` + 'PASS' ` + $true ` + "id=$($preview.id); requestedBranch=$ProbeBranch; module=$ProbeModule; stages=$($stages -join ',')" + } + catch + { + Add-SetupCheck 'PipelinePreview' 'FAIL' $true (Get-FixedError $_ 'Pipeline preview failed.') + } +} +else +{ + Add-SetupCheck 'PipelinePreview' 'SKIP' $true 'Pipeline definition was not resolved.' +} + +try +{ + if ($null -eq $probeBuild) + { + throw 'No probe build is available for a repeated read.' + } + + $secondRead = (Invoke-AzDevOpsRest ` + -Uri "_apis/build/builds/$($probeBuild.id)?api-version=7.1" ` + -Organization $Organization ` + -Project $Project).Body + Add-SetupCheck 'RepeatedPromptFreeRead' 'PASS' $true "id=$($secondRead.id); prompts=0" +} +catch +{ + Add-SetupCheck 'RepeatedPromptFreeRead' 'FAIL' $true (Get-FixedError $_ 'Repeated prompt-free read failed.') +} + +$requiredFailures = @($checks | Where-Object { $_.Required -and $_.Status -ne 'PASS' }) +$summary = [pscustomobject]@{ + Ready = $requiredFailures.Count -eq 0 + Organization = $Organization + Project = $Project + PipelineName = $PipelineName + PipelineId = $pipelineId + ProbeBuildId = if ($probeBuild) { [long]$probeBuild.id } else { $null } + ProbeTestBuildId = if ($probeTestBuild) { [long]$probeTestBuild.id } else { $null } + ProbeBranch = $ProbeBranch + ProbeModule = $ProbeModule + Checks = $checks.ToArray() + ProvenWithoutMutation = @( + 'cached Azure CLI sign-in and Azure DevOps token acquisition', + 'project, definition, build, timeline, log, artifact, test-run, result, and attachment reads', + 'Run Pipeline API access and YAML expansion through previewRun=true', + 'repeated prompt-free token-backed REST reads' + ) + DeliberatelyNotMutated = @( + 'creating an actual pipeline run', + 'canceling a build', + 'retrying or canceling a stage' + ) + NextStep = if ($requiredFailures.Count -eq 0) + { + 'Setup is ready. Re-run this preflight after account changes or any 401/403 response.' + } + else + { + 'Setup is not ready. Resolve every required FAIL/SKIP item before queueing or monitoring CI.' + } +} + +$summary | ConvertTo-Json -Depth 8 +if (-not $summary.Ready) +{ + exit 1 +} \ No newline at end of file diff --git a/.pipelines/removeTestSigningCertificates.ps1 b/.pipelines/removeTestSigningCertificates.ps1 new file mode 100644 index 0000000000..a8f1846aa9 --- /dev/null +++ b/.pipelines/removeTestSigningCertificates.ps1 @@ -0,0 +1,72 @@ +<# +.SYNOPSIS +Remove PowerToys UI-test signing certificates recorded in an exact-thumbprint marker. + +.DESCRIPTION +Deletes each recorded certificate from the machine/user trust stores and removes the CurrentUser +private key. The marker is deleted only after every store verifies clean, so an interrupted or failed +cleanup remains retryable by a later job. + +.PARAMETER CertificateMarkerPath +Durable marker populated by signSparsePackages.ps1. Each non-empty line must be a SHA-1 certificate +thumbprint. +#> +param( + [Parameter(Mandatory = $true)] + [string]$CertificateMarkerPath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $CertificateMarkerPath -ErrorAction Stop)) { + Write-Host 'No PowerToys UI-test signing certificate marker was found.' + return +} + +$markerEntries = @(Get-Content -LiteralPath $CertificateMarkerPath -ErrorAction Stop | + ForEach-Object { $_.Trim() } | + Where-Object { $_ }) +$invalidEntries = @($markerEntries | Where-Object { $_ -notmatch '^[0-9A-Fa-f]{40}$' }) +if ($invalidEntries.Count -gt 0) { + throw "PowerToys UI-test signing certificate marker contains invalid thumbprints: $($invalidEntries -join ', ')." +} + +$thumbprints = @($markerEntries | ForEach-Object { $_.ToUpperInvariant() } | Select-Object -Unique) +$trustStorePaths = @( + 'Cert:\LocalMachine\Root', + 'Cert:\LocalMachine\TrustedPeople', + 'Cert:\CurrentUser\TrustedPeople') + +foreach ($thumbprint in $thumbprints) { + foreach ($storePath in $trustStorePaths) { + $certificatePath = Join-Path $storePath $thumbprint + if (Test-Path -LiteralPath $certificatePath -ErrorAction Stop) { + Remove-Item -LiteralPath $certificatePath -Force -ErrorAction Stop + } + } + + $privateCertificatePath = Join-Path 'Cert:\CurrentUser\My' $thumbprint + if (Test-Path -LiteralPath $privateCertificatePath -ErrorAction Stop) { + Remove-Item -LiteralPath $privateCertificatePath -DeleteKey -Force -ErrorAction Stop + } + + Remove-Item -LiteralPath (Join-Path $env:TEMP "pt-test-signer-$thumbprint.cer") ` + -Force -ErrorAction SilentlyContinue +} + +$allStorePaths = @($trustStorePaths) + 'Cert:\CurrentUser\My' +$remaining = foreach ($thumbprint in $thumbprints) { + foreach ($storePath in $allStorePaths) { + $certificatePath = Join-Path $storePath $thumbprint + if (Test-Path -LiteralPath $certificatePath -ErrorAction Stop) { + $certificatePath + } + } +} + +if ($remaining) { + throw "PowerToys UI-test signing certificate cleanup failed: $($remaining -join ', ')." +} + +Remove-Item -LiteralPath $CertificateMarkerPath -Force -ErrorAction Stop +Write-Host "Removed PowerToys UI-test signing certificate(s): $($thumbprints -join ', ')." diff --git a/.pipelines/signSparsePackages.ps1 b/.pipelines/signSparsePackages.ps1 index f2b3fc3c57..17eba6708a 100644 --- a/.pipelines/signSparsePackages.ps1 +++ b/.pipelines/signSparsePackages.ps1 @@ -34,6 +34,19 @@ Filename patterns to sign. Defaults to *.msix and *.appx. Filename patterns that must be found and end with a Valid signature. Missing, unsigned, or untrusted matches make the script fail after attempting all packages. +.PARAMETER RequiredAuthenticodeFile +Filename patterns for unpackaged companion binaries that must be found and signed with the same +machine-trusted TEST identity. This is used for authenticated PowerToys IPC on unsigned CI builds. + +.PARAMETER AuthenticodePublisher +Certificate subject used to sign RequiredAuthenticodeFile matches. The default matches the +Microsoft publisher identity required by PowerToys' Release IPC caller authentication. + +.PARAMETER CertificateMarkerPath +Optional durable text file that receives each test certificate thumbprint used by this invocation. +CI uses the marker before signing and from an always() cleanup step to remove the trust anchor +and private key, including after an interrupted prior job. + .PARAMETER Force Re-sign even packages that already carry a valid signature. @@ -68,6 +81,15 @@ param( [Parameter()] [string[]]$RequiredPackage = @(), + [Parameter()] + [string[]]$RequiredAuthenticodeFile = @(), + + [Parameter()] + [string]$AuthenticodePublisher = 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + + [Parameter()] + [string]$CertificateMarkerPath, + [switch]$Force, [switch]$SkipLocalTrust, @@ -78,6 +100,7 @@ param( $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem +$testCertificateFriendlyName = 'PowerToys UI Test Signing' function Select-SignToolByArch { param([string[]]$Paths) @@ -94,7 +117,7 @@ function Select-SignToolByArch { } # Locate signtool.exe on the agent: PATH, then any Windows Kits install (all versions/layouts, -# including the App Certification Kit), then a restored SDK BuildTools NuGet package. +# including the App Certification Kit). The pinned, signature-verified NuGet fallback is separate. function Find-SignTool { $cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } @@ -116,34 +139,50 @@ function Find-SignTool { } } - $nugetRoots = @($env:NUGET_PACKAGES, (Join-Path $env:USERPROFILE '.nuget\packages')) | - Where-Object { $_ } | - ForEach-Object { Join-Path $_ 'microsoft.windows.sdk.buildtools' } | - Where-Object { Test-Path $_ } | Select-Object -Unique - foreach ($root in $nugetRoots) { - $found += Get-ChildItem -Path $root -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty FullName - } - return Select-SignToolByArch -Paths $found } # Last resort when the agent has no Windows SDK: fetch signtool from the public -# Microsoft.Windows.SDK.BuildTools NuGet package (cached in TEMP across runs). Best-effort. +# Microsoft.Windows.SDK.BuildTools NuGet package. Best-effort. function Get-SignToolFromNuget { + $nupkg = $null + $archive = $null try { - $index = Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/index.json' -UseBasicParsing - $version = @($index.versions | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' })[-1] - if (-not $version) { return $null } + [xml]$centralPackages = Get-Content (Join-Path $PSScriptRoot '..\Directory.Packages.props') -Raw + $versionNode = $centralPackages.SelectSingleNode( + "/Project/ItemGroup/PackageVersion[@Include='Microsoft.Windows.SDK.BuildTools']") + $version = if ($versionNode) { $versionNode.GetAttribute('Version') } else { $null } + if ($version -notmatch '^\d+\.\d+\.\d+\.\d+$') { + throw 'Microsoft.Windows.SDK.BuildTools must have a concrete four-part version in Directory.Packages.props.' + } $dest = Join-Path $env:TEMP "pt-sdk-buildtools-$version" - if (-not (Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue)) { - Write-Host "signtool not found on the agent; fetching Windows SDK BuildTools $version from NuGet." - $nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.zip" - Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/$version/microsoft.windows.sdk.buildtools.$version.nupkg" -OutFile $nupkg -UseBasicParsing - Expand-Archive -Path $nupkg -DestinationPath $dest -Force - Remove-Item $nupkg -Force -ErrorAction SilentlyContinue + $nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.nupkg" + $archive = Join-Path $env:TEMP "sdk-buildtools-$version.zip" + $packageFileName = "microsoft.windows.sdk.buildtools.$version.nupkg" + $cachedPackage = @($env:NUGET_PACKAGES, (Join-Path $env:USERPROFILE '.nuget\packages')) | + Where-Object { $_ } | + ForEach-Object { Join-Path $_ "microsoft.windows.sdk.buildtools\$version\$packageFileName" } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + if ($cachedPackage) { + Write-Host "signtool not found on the agent; verifying cached Windows SDK BuildTools $version." + Copy-Item -LiteralPath $cachedPackage -Destination $nupkg -Force } + else { + Write-Host "signtool not found on the agent; fetching Windows SDK BuildTools $version from NuGet." + Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/$version/$packageFileName" -OutFile $nupkg -UseBasicParsing + } + + $verificationOutput = @(& dotnet nuget verify $nupkg --all 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "NuGet signature verification failed for Microsoft.Windows.SDK.BuildTools ${version}: $($verificationOutput -join [Environment]::NewLine)" + } + $verificationOutput | ForEach-Object { Write-Host $_ } + + Copy-Item $nupkg $archive -Force + Remove-Item $dest -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -Path $archive -DestinationPath $dest -Force $paths = Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName return Select-SignToolByArch -Paths $paths @@ -152,6 +191,28 @@ function Get-SignToolFromNuget { Write-Warning "Could not obtain signtool from NuGet: $($_.Exception.Message)" return $null } + finally { + @($nupkg, $archive) | Where-Object { $_ } | ForEach-Object { + Remove-Item -LiteralPath $_ -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-VerifiedSignTool { + $path = Find-SignTool + if (-not $path) { $path = Get-SignToolFromNuget } + if (-not $path) { + throw 'signtool.exe not found and could not be fetched from NuGet. Install the Windows SDK.' + } + + $signature = Get-AuthenticodeSignature -FilePath $path + if ($signature.Status -ne 'Valid' -or + -not $signature.SignerCertificate -or + $signature.SignerCertificate.Subject -notmatch '(^|,\s*)O=Microsoft Corporation(,|$)') { + throw "signtool.exe is not validly signed by Microsoft: $path" + } + + return $path } # Read straight out of the .msix/.appx (a zip) without extracting it. @@ -202,18 +263,39 @@ function Get-TrustedSigningCert { if ($certCache.ContainsKey($Subject)) { return $certCache[$Subject] } $cert = Get-ChildItem Cert:\CurrentUser\My | - Where-Object { $_.Subject -eq $Subject -and $_.HasPrivateKey } | + Where-Object { + $_.Subject -eq $Subject -and + $_.HasPrivateKey -and + $_.FriendlyName -eq $testCertificateFriendlyName + } | Sort-Object NotAfter -Descending | Select-Object -First 1 if (-not $cert) { Write-Host "Creating self-signed test certificate for: $Subject" $cert = New-SelfSignedCertificate -Subject $Subject ` -CertStoreLocation Cert:\CurrentUser\My ` + -FriendlyName $testCertificateFriendlyName ` -KeyAlgorithm RSA -KeyLength 2048 ` -Type CodeSigningCert -HashAlgorithm SHA256 ` -NotAfter (Get-Date).AddYears(1) } + if ($CertificateMarkerPath) { + $markerParent = Split-Path $CertificateMarkerPath -Parent + if ($markerParent -and -not (Test-Path $markerParent)) { + New-Item $markerParent -ItemType Directory -Force | Out-Null + } + + $recordedThumbprints = if (Test-Path $CertificateMarkerPath) { + @(Get-Content $CertificateMarkerPath | Where-Object { $_ }) + } else { + @() + } + if ($recordedThumbprints -notcontains $cert.Thumbprint) { + Add-Content -Path $CertificateMarkerPath -Value $cert.Thumbprint -Encoding ascii + } + } + # Force-trust so AddPackageByUriAsync accepts the signature. A self-signed cert is its own root, # so it must live in a Root store (chain) and TrustedPeople (AppX sideload allow-list). Use the # LocalMachine stores: they import silently and the elevated CI test agent can write them. @@ -258,12 +340,30 @@ foreach ($root in $PackageRoot) { } $packages = $packages | Sort-Object FullName -Unique -if (-not $packages) { - if ($RequiredPackage.Count -gt 0) { - throw "No packages found under '$($PackageRoot -join ', ')' while requiring: $($RequiredPackage -join ', ')." +$requiredAuthenticodeFiles = @() +foreach ($pattern in ($RequiredAuthenticodeFile | Where-Object { $_ } | Select-Object -Unique)) { + $matches = @() + foreach ($root in $PackageRoot) { + if (Test-Path $root) { + $matches += Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue + } } - Write-Host "No packages found under: $($PackageRoot -join ', ')" + $matches = @($matches | Sort-Object FullName -Unique) + if ($matches.Count -eq 0) { + throw "Required Authenticode file '$pattern' was not found under: $($PackageRoot -join ', ')." + } + + $requiredAuthenticodeFiles += $matches +} +$requiredAuthenticodeFiles = @($requiredAuthenticodeFiles | Sort-Object FullName -Unique) + +if (-not $packages -and $RequiredPackage.Count -gt 0) { + throw "No packages found under '$($PackageRoot -join ', ')' while requiring: $($RequiredPackage -join ', ')." +} + +if (-not $packages -and $requiredAuthenticodeFiles.Count -eq 0) { + Write-Host "No packages or required Authenticode files found under: $($PackageRoot -join ', ')" return } @@ -297,9 +397,7 @@ foreach ($pkg in $packages) { } if (-not $signtool) { - $signtool = Find-SignTool - if (-not $signtool) { $signtool = Get-SignToolFromNuget } - if (-not $signtool) { throw 'signtool.exe not found and could not be fetched from NuGet. Install the Windows SDK.' } + $signtool = Get-VerifiedSignTool Write-Host "Using signtool: $signtool" } @@ -331,4 +429,56 @@ if ($requiredPackages.Count -gt 0) { Write-Host "Verified required sparse package(s): $($requiredPackages.FullName -join ', ')" } +if ($requiredAuthenticodeFiles.Count -gt 0) { + if (-not $signtool) { + $signtool = Get-VerifiedSignTool + Write-Host "Using signtool: $signtool" + } + + $filesToSign = @($requiredAuthenticodeFiles | Where-Object { + $existing = Get-AuthenticodeSignature -FilePath $_.FullName + $Force -or $existing.Status -ne 'Valid' + }) + $testSignedPaths = @{} + $cert = if ($filesToSign.Count -gt 0) { + Get-TrustedSigningCert -Subject $AuthenticodePublisher + } else { + $null + } + + foreach ($file in $filesToSign) { + + Write-Host "Signing companion binary: $($file.FullName)" + & $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $file.FullName + if ($LASTEXITCODE -ne 0) { + throw "signtool failed for required Authenticode file '$($file.FullName)' (exit $LASTEXITCODE)." + } + + $testSignedPaths[$file.FullName] = $true + } + + $invalidAuthenticodeFiles = @($requiredAuthenticodeFiles | Where-Object { + $signature = Get-AuthenticodeSignature -FilePath $_.FullName + $signerName = if ($signature.SignerCertificate) { + $signature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + } else { + $null + } + if ($testSignedPaths.ContainsKey($_.FullName)) { + -not $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $cert.Thumbprint -or + (-not $SkipLocalTrust -and $signature.Status -ne 'Valid') + } else { + -not $signature.SignerCertificate -or + $signerName -ne 'Microsoft Corporation' -or + $signature.Status -ne 'Valid' + } + }) + if ($invalidAuthenticodeFiles.Count -gt 0) { + throw "Required Authenticode file(s) are not signed with the trusted test identity: $($invalidAuthenticodeFiles.FullName -join ', ')." + } + + Write-Host "Verified required Authenticode file(s): $($requiredAuthenticodeFiles.FullName -join ', ')" +} + Write-Host "Signed $signed package(s) with a trusted test certificate." diff --git a/.pipelines/v2/templates/job-test-project.yml b/.pipelines/v2/templates/job-test-project.yml index bc590f2f32..c4289fb4e1 100644 --- a/.pipelines/v2/templates/job-test-project.yml +++ b/.pipelines/v2/templates/job-test-project.yml @@ -67,6 +67,11 @@ jobs: fetchDepth: 1 fetchTags: false + - pwsh: | + & "$(build.sourcesdirectory)\.pipelines\removeTestSigningCertificates.ps1" ` + -CertificateMarkerPath "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt" + displayName: Remove stale UI-test signing certificates + - ${{ if eq(parameters.useLatestWebView2, true) }}: - powershell: | $edge_url = 'https://go.microsoft.com/fwlink/?linkid=2084649&Channel=Canary&language=en' @@ -181,25 +186,54 @@ jobs: # register on unsigned PR builds and the UI tests can drive the real modern (Win11 tier-1) context # menu instead of the signing-free fallback. Test-only trust anchor; asserts no security. All roots # are searched recursively (buildNow run-in-place tree + complete machine/per-user installs). - # Image Resizer has no Windows 11 classic-menu fallback, so its focused and all-module jobs require - # the signed/trusted package; unrelated jobs keep this setup best-effort. + # Focused and all-module jobs that exercise a modern context menu require the corresponding + # signed/trusted package; unrelated jobs keep this setup best-effort. The certificate thumbprint + # is recorded under Agent.WorkFolder and removed from every trust/private-key store before signing + # and in the always-running cleanup step below. The durable marker makes interrupted cleanup + # retryable on persistent agents. - pwsh: | $packageRoots = @( "$(Pipeline.Workspace)\$(TestArtifactsName)", "$env:ProgramFiles\PowerToys", "$env:LOCALAPPDATA\PowerToys") $modulesRaw = '${{ join(';', parameters.uiTestModules) }}' - $requiresImageResizer = '$(TestPlatform)' -ne 'x64Win10' -and ( - [string]::IsNullOrWhiteSpace($modulesRaw) -or - @($modulesRaw -split ';' | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0) + $selectedModules = @($modulesRaw -split ';' | Where-Object { $_ }) + $allModules = [string]::IsNullOrWhiteSpace($modulesRaw) + $requiresModernContextMenu = '$(TestPlatform)' -ne 'x64Win10' + $requiresPowerRename = $allModules -or @($selectedModules | Where-Object { $_ -match 'PowerRename' }).Count -gt 0 + $requiredPackages = @() + $requiredAuthenticodeFiles = @() + $certificateMarkerPath = "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt" - if ($requiresImageResizer) { - & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" ` - -PackageRoot $packageRoots ` - -RequiredPackage 'ImageResizerContextMenuPackage.msix' + if ($requiresModernContextMenu -and ($allModules -or @($selectedModules | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0)) { + $requiredPackages += 'ImageResizerContextMenuPackage.msix' + } + + if ($requiresModernContextMenu -and $requiresPowerRename) { + $requiredPackages += 'PowerRenameContextMenuPackage.msix' + } + + if ($requiresPowerRename) { + $requiredAuthenticodeFiles += 'PowerToys.exe', 'PowerToys.Settings.exe' + } + + if ($requiredPackages.Count -gt 0 -or $requiredAuthenticodeFiles.Count -gt 0) { + $signingArguments = @{ + PackageRoot = $packageRoots + CertificateMarkerPath = $certificateMarkerPath + } + 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 $packageRoots + & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" ` + -PackageRoot $packageRoots ` + -CertificateMarkerPath $certificateMarkerPath } catch { Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)" } @@ -328,3 +362,9 @@ jobs: Get-Process -Name 'WinAppDriver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue displayName: Stop WinAppDriver condition: always() + + - pwsh: | + & "$(build.sourcesdirectory)\.pipelines\removeTestSigningCertificates.ps1" ` + -CertificateMarkerPath "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt" + displayName: Remove UI-test signing certificates + condition: always() diff --git a/PowerToys.slnx b/PowerToys.slnx index c85cacb360..e6b34c2baa 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -973,6 +973,10 @@ + + + + diff --git a/doc/devdocs/development/ui-tests.md b/doc/devdocs/development/ui-tests.md index 443873a778..47e8418450 100644 --- a/doc/devdocs/development/ui-tests.md +++ b/doc/devdocs/development/ui-tests.md @@ -20,6 +20,10 @@ For new or migrated tests, use both skills. Build first, then use the local VMs agentic loop: run one deterministic test, diagnose and fix it, and finally widen to the complete module suite on both supported Windows versions. +Module-specific constraints are documented with the module; for example, see the +[PowerRename UI-test notes](../modules/powerrename.md#ui-tests) for command-line selection, Boost +engine lifetime, and signed shell-extension requirements. + ## Before running tests ### `.Next` tests diff --git a/doc/devdocs/modules/powerrename.md b/doc/devdocs/modules/powerrename.md index e7b773e70c..361ff24299 100644 --- a/doc/devdocs/modules/powerrename.md +++ b/doc/devdocs/modules/powerrename.md @@ -87,6 +87,24 @@ PowerRename uses WinUI 3 for its user interface. The UI allows users to: - Settings panel for configuring rename options - Event handling for `SearchReplaceChanged` to update the preview in real-time +## UI tests + +The migrated `PowerRename.UITests.Next` project drives the module through +`Microsoft.PowerToys.UITest.Next` and `winappcli`. Keep these module-specific constraints in mind: + +- The PowerRename UI receives its selected files on the command line. Tests launch + `PowerToys.PowerRename.exe` directly with an argument per path, while retaining the runner/Settings + scope for module enablement and shell registration. +- Windows 11 tier-1 context-menu tests require a signed and trusted + `PowerRenameContextMenuPackage.msix`. Unsigned builds can still exercise the classic menu. +- `UseBoostLib` is read when the regex engine is constructed, so changing it requires a fresh + PowerRename process. +- Shell handlers read global/module settings independently. Tests wait for persisted settings and + restart Explorer after registration changes instead of treating the Settings control state as the + final signal. + +See the [UI tests framework](../development/ui-tests.md) for build, local-VM, and pipeline workflows. + ## Debugging ### Debugging the Context Menu diff --git a/src/common/UITestAutomation.Next/SessionHelper.cs b/src/common/UITestAutomation.Next/SessionHelper.cs index d2d82ca4f7..ceb5a9e149 100644 --- a/src/common/UITestAutomation.Next/SessionHelper.cs +++ b/src/common/UITestAutomation.Next/SessionHelper.cs @@ -99,10 +99,26 @@ public sealed class SessionHelper /// Process name as winappcli's -a flag (and ) accept it. public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope); + /// + /// Full path to the executable backing , resolved against the build under + /// test (or an installed build). Exposed for the few modules a test must launch itself because the + /// scope needs arguments the harness cannot supply — PowerRename takes its item list on the + /// command line. + /// + public static string GetExecutablePath(PowerToysModule scope) => ModulePaths.ExePathFor(scope); + /// Returns true if at least one process matching is running. public static bool IsRunning(PowerToysModule scope) => Process.GetProcessesByName(GetProcessName(scope)).Length > 0; + /// + /// Returns true when the scope's full owning environment is alive. Settings is healthy + /// only while both its window process and the runner that owns module lifecycle are running. + /// + internal static bool IsScopeHealthy(PowerToysModule scope) => + IsRunning(scope) && + (scope != PowerToysModule.PowerToysSettings || IsRunning(PowerToysModule.Runner)); + /// /// Ensure the runner-owned environment for is up and has presented a /// UIA-visible window. Returns false when the target was already running (nothing @@ -138,10 +154,10 @@ public sealed class SessionHelper { // Whether or not the scope process already exists, the test needs its WINDOW. EnsureWindow // waits patiently and (idempotently) re-issues the launch as needed; it only kills/relaunches - // a genuinely-dead fresh launch, never a slow-but-healthy or class-shared (reused) window. - var alreadyRunning = IsRunning(scope); - EnsureWindow(scope, timeout, alreadyRunning); - return !alreadyRunning; + // a genuinely-dead fresh launch or an orphaned Settings window whose runner has exited. + var alreadyRunning = IsScopeHealthy(scope); + var recoveredByUs = EnsureWindow(scope, timeout, alreadyRunning); + return !alreadyRunning || recoveredByUs; } /// @@ -158,20 +174,26 @@ public sealed class SessionHelper /// the runner is single-instance, so --open-settings just (re)shows Settings — and /// additionally clears the single-instance mutex first only for a fresh launch that has gone /// completely dead (nothing running), i.e. the handoff-to-a-now-exited-instance race. A - /// class-shared (reused) window is never killed. + /// class-shared (reused) window is never killed while its runner remains healthy. An orphaned + /// Settings window cannot process module lifecycle commands, so recovery replaces it and keeps + /// waiting under the original deadline. /// - private static void EnsureWindow(PowerToysModule scope, TimeSpan timeout, bool alreadyRunning) + private static bool EnsureWindow(PowerToysModule scope, TimeSpan timeout, bool alreadyRunning) { var processName = GetProcessName(scope); var runnerName = GetProcessName(PowerToysModule.Runner); var nudgeInterval = TimeSpan.FromSeconds(25); + var recoveredByUs = false; if (!alreadyRunning) { // Release the single-instance mutex any stale/half-launched instance still holds (pre-test // hygiene kills without waiting), then launch. - KillScopeProcessesAndWait(scope); - LaunchScope(scope); + if (TryKillScopeProcessesAndWait(scope, TimeSpan.FromSeconds(10), out _)) + { + LaunchScope(scope); + recoveredByUs = true; + } } var deadline = DateTime.UtcNow + timeout; @@ -179,11 +201,11 @@ public sealed class SessionHelper while (DateTime.UtcNow < deadline) { - if (WindowsFinder.ListByApp(processName).Count > 0) + if (IsScopeHealthy(scope) && WindowsFinder.ListByApp(processName).Count > 0) { // Give XAML a moment to populate the visual tree. Thread.Sleep(750); - return; + return recoveredByUs; } if (DateTime.UtcNow - lastLaunch > nudgeInterval) @@ -196,16 +218,26 @@ public sealed class SessionHelper // honours with a SEPARATE Settings.exe (the "Settings: 3" pile-up seen in CI), and the // competing single-instance processes plus the launch contention push the window past // the deadline. So when anything is alive, keep waiting instead of piling on. - var alive = IsRunning(scope) || Process.GetProcessesByName(runnerName).Length > 0; - if (!alive) + var scopeAlive = IsRunning(scope); + var runnerAlive = IsRunning(PowerToysModule.Runner); + var orphanedSettings = scope == PowerToysModule.PowerToysSettings && scopeAlive && !runnerAlive; + if (orphanedSettings) { - if (!alreadyRunning) + if (TryKillScopeProcessesAndWait(scope, TimeSpan.FromSeconds(10), out _)) { - KillScopeProcessesAndWait(scope); + LaunchScope(scope); + recoveredByUs = true; + lastLaunch = DateTime.UtcNow; + } + } + else if (!scopeAlive && !runnerAlive) + { + if (alreadyRunning || TryKillScopeProcessesAndWait(scope, TimeSpan.FromSeconds(10), out _)) + { + LaunchScope(scope); + recoveredByUs = true; + lastLaunch = DateTime.UtcNow; } - - LaunchScope(scope); - lastLaunch = DateTime.UtcNow; } } @@ -216,6 +248,7 @@ public sealed class SessionHelper $"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds:0}s. " + $"Live processes — runner '{runnerName}': {Process.GetProcessesByName(runnerName).Length}, " + $"'{processName}': {Process.GetProcessesByName(processName).Length}."); + return recoveredByUs; } /// @@ -242,6 +275,18 @@ public sealed class SessionHelper /// new launch off to the dying instance, which never presents a window. /// private static void KillScopeProcessesAndWait(PowerToysModule scope) + { + if (!TryKillScopeProcessesAndWait(scope, TimeSpan.FromSeconds(30), out var remaining)) + { + throw new InvalidOperationException( + $"Could not stop the {scope} scope within 30 seconds. Remaining processes: {string.Join(", ", remaining)}."); + } + } + + private static bool TryKillScopeProcessesAndWait( + PowerToysModule scope, + TimeSpan timeout, + out IReadOnlyList remaining) { var names = scope == PowerToysModule.PowerToysSettings ? new[] { GetProcessName(PowerToysModule.PowerToysSettings), GetProcessName(PowerToysModule.Runner) } @@ -252,11 +297,40 @@ public sealed class SessionHelper WindowControl.TryKillProcessByName(name); } - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); - while (DateTime.UtcNow < deadline && names.Any(n => Process.GetProcessesByName(n).Length > 0)) + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) { + remaining = GetRunningProcessNames(names); + if (remaining.Count == 0) + { + return true; + } + Thread.Sleep(150); } + + remaining = GetRunningProcessNames(names); + return remaining.Count == 0; + } + + private static IReadOnlyList GetRunningProcessNames(IEnumerable names) + { + var running = new List(); + foreach (var name in names) + { + var processes = Process.GetProcessesByName(name); + if (processes.Length > 0) + { + running.Add(name); + } + + foreach (var process in processes) + { + process.Dispose(); + } + } + + return running; } /// diff --git a/src/common/UITestAutomation.Next/UITestBase.cs b/src/common/UITestAutomation.Next/UITestBase.cs index d7a61d9651..3930e54aef 100644 --- a/src/common/UITestAutomation.Next/UITestBase.cs +++ b/src/common/UITestAutomation.Next/UITestBase.cs @@ -35,6 +35,7 @@ public class UITestBase : IDisposable // inherited ClassCleanup stops it once the owning class finishes. private static SessionHelper? keepAliveHelper; private static Type? keepAliveOwner; + private static IDisposable? keepAliveSettingsSnapshot; private readonly PowerToysModule scope; private readonly WindowSize windowSize; @@ -110,9 +111,8 @@ public class UITestBase : IDisposable { // Reuse the already-open window from a previous test in this class when the class opted // into a shared scope and it's still alive — skip the hygiene that would minimize/kill it. - var reuse = ReuseScopeAcrossTests - && keepAliveOwner == GetType() - && SessionHelper.IsRunning(scope); + var ownsSharedScope = ReuseScopeAcrossTests && keepAliveOwner == GetType(); + var reuse = ownsSharedScope && SessionHelper.IsScopeHealthy(scope); if (!reuse) { @@ -124,7 +124,11 @@ public class UITestBase : IDisposable DisplayHelper.LogMonitors(TestContext); } - firstRunSettingsSnapshot = SettingsConfigHelper.PreserveFirstRunSettings(); + if (!ownsSharedScope) + { + firstRunSettingsSnapshot = SettingsConfigHelper.PreserveFirstRunSettings(); + } + PreTestHygiene(); // Seed a deterministic module on/off baseline before the runner reads settings.json. @@ -154,6 +158,11 @@ public class UITestBase : IDisposable { keepAliveHelper = sessionHelper; keepAliveOwner = GetType(); + if (!ownsSharedScope) + { + keepAliveSettingsSnapshot = firstRunSettingsSnapshot; + firstRunSettingsSnapshot = null; + } } } catch @@ -206,13 +215,7 @@ public class UITestBase : IDisposable // window must survive for the next test; the inherited ClassCleanup stops it at class end. if (!ReuseScopeAcrossTests) { - try - { - sessionHelper?.StopIfStarted(); - } - catch - { - } + sessionHelper?.StopIfStarted(); } } finally @@ -226,19 +229,39 @@ public class UITestBase : IDisposable /// tests finish. Runs after every derived class via inheritance; a no-op for classes that never /// kept a scope alive. /// - [ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass)] + [ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass, ClassCleanupBehavior.EndOfClass)] public static void StopSharedScope() { + var failures = new List(); try { keepAliveHelper?.StopIfStarted(); } - catch + catch (Exception ex) { + failures.Add(ex); + } + finally + { + keepAliveHelper = null; + keepAliveOwner = null; } - keepAliveHelper = null; - keepAliveOwner = null; + try + { + var snapshot = keepAliveSettingsSnapshot; + keepAliveSettingsSnapshot = null; + snapshot?.Dispose(); + } + catch (Exception ex) + { + failures.Add(ex); + } + + if (failures.Count > 0) + { + throw new AggregateException("Shared UI-test scope cleanup failed.", failures); + } } /// diff --git a/src/modules/powerrename/PowerRename.UITests.Next/ClassicContextMenu.cs b/src/modules/powerrename/PowerRename.UITests.Next/ClassicContextMenu.cs new file mode 100644 index 0000000000..ad75435a6f --- /dev/null +++ b/src/modules/powerrename/PowerRename.UITests.Next/ClassicContextMenu.cs @@ -0,0 +1,90 @@ +// 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.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.PowerToys.PowerRename.UITests; + +/// +/// Reads the item captions of the real HMENU behind a classic (#32768) context-menu +/// window, by asking the window for its menu handle (MN_GETHMENU). +/// +/// +/// This is the authoritative view of what the shell put in the menu: exact captions, no dependency on +/// the transient popup's UIA tree, and a miss can report the full inventory instead of just "not +/// found". Measured on Windows 10 and 11, GetMenuItemInfo(MIIM_BITMAP) returns a null +/// hbmpItem for every Explorer menu item, so icon presence is measured from pixels instead. +/// +internal static class ClassicContextMenu +{ + /// Window class of a classic Win32 popup menu. + public const string WindowClassName = "#32768"; + + private const uint MNGETHMENU = 0x01E1; + private const uint MFBYPOSITION = 0x00000400; + private const uint SMTOABORTIFHUNG = 0x0002; + + /// + /// Captions of every item of the popup menu owned by , or null when + /// the window no longer owns a menu (a transient popup can vanish mid-read). + /// + public static IReadOnlyList? TryReadItemCaptions(IntPtr menuWindow) + { + if (menuWindow == IntPtr.Zero) + { + return null; + } + + if (SendMessageTimeoutW(menuWindow, MNGETHMENU, IntPtr.Zero, IntPtr.Zero, SMTOABORTIFHUNG, 2_000, out var menu) == IntPtr.Zero || + menu == IntPtr.Zero) + { + return null; + } + + var count = GetMenuItemCount(menu); + if (count <= 0) + { + return null; + } + + var captions = new List(count); + var buffer = new StringBuilder(512); + for (var index = 0; index < count; index++) + { + buffer.Clear(); + var length = GetMenuStringW(menu, (uint)index, buffer, buffer.Capacity, MFBYPOSITION); + captions.Add(NormalizeCaption(length > 0 ? buffer.ToString() : string.Empty)); + } + + return captions; + } + + private static string NormalizeCaption(string caption) + { + var accelerator = caption.IndexOf('\t'); + if (accelerator >= 0) + { + caption = caption[..accelerator]; + } + + return caption.Replace("&", string.Empty, StringComparison.Ordinal).Trim(); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SendMessageTimeoutW( + IntPtr hWnd, + uint msg, + IntPtr wParam, + IntPtr lParam, + uint flags, + uint timeoutMS, + out IntPtr result); + + [DllImport("user32.dll")] + private static extern int GetMenuItemCount(IntPtr menu); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetMenuStringW(IntPtr menu, uint item, StringBuilder text, int maxCount, uint flags); +} diff --git a/src/modules/powerrename/PowerRename.UITests.Next/PowerRename.UITests.Next.csproj b/src/modules/powerrename/PowerRename.UITests.Next/PowerRename.UITests.Next.csproj new file mode 100644 index 0000000000..e8dae4b3c0 --- /dev/null +++ b/src/modules/powerrename/PowerRename.UITests.Next/PowerRename.UITests.Next.csproj @@ -0,0 +1,38 @@ + + + + + + Exe + net10.0-windows10.0.26100.0 + enable + enable + false + false + Microsoft.PowerToys.PowerRename.UITests + PowerRename.UITests.Next + + app.manifest + + true + true + false + + + false + + + + + $(RepoRoot)$(Platform)\$(Configuration)\tests\PowerRename.UITests.Next\ + + + + + + + + + + diff --git a/src/modules/powerrename/PowerRename.UITests.Next/PowerRenameContextMenuTests.cs b/src/modules/powerrename/PowerRename.UITests.Next/PowerRenameContextMenuTests.cs new file mode 100644 index 0000000000..b1714e5b93 --- /dev/null +++ b/src/modules/powerrename/PowerRename.UITests.Next/PowerRenameContextMenuTests.cs @@ -0,0 +1,778 @@ +// 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.Diagnostics; +using System.Drawing; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.PowerRename.UITests; + +/// +/// PowerRename's Explorer integration: the entry's presence on both context-menu surfaces, its icon, +/// the extended-menu-only setting, and the real invoke path into the PowerRename window. +/// +/// Covers checklist items 1-3 of microsoft/PowerToys#40663. +[TestClass] +[DoNotParallelize] +public sealed partial class PowerRenameTests : PowerRenameTestBase +{ + private const string ExplorerProcessName = "explorer"; + private const string ModernContextMenuClassName = "Microsoft.UI.Content.PopupWindowSiteBridge"; + private const string ModernPackageName = "PowerRenameContextMenu"; + private const string ShowMoreOptionsCaption = "Show more options"; + private const string ModuleToggleName = "PowerRename"; + private const int ExplorerTimeoutMS = 30_000; + private const int MenuSurfaceTimeoutMS = 25_000; + private const int MenuAttemptTimeoutMS = 90_000; + + private static bool explorerRefreshedForRegistration; + private bool contextMenuTest; + + [TestCleanup] + public async Task CleanupContextMenuTest() + { + if (!contextMenuTest) + { + return; + } + + // Capture first: the base cleanup runs last, and by then Explorer is already gone. + await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); + KeyboardHelper.SendKeys(Key.Esc); + CloseExplorerFileWindows(); + } + + [TestMethod("PowerRename.ContextMenu.EnabledState")] + [TestCategory("PowerRename")] + public void ContextMenuTracksModuleEnabledState() + { + PrepareContextMenuTest(); + + // Checklist item 1 — and on Windows 11 both the tier-1 and the classic surface must carry it. + var settings = NavigateToPowerRenameSettings(); + var toggle = FindExact(settings, ModuleToggleName, timeoutMS: 15_000); + Assert.IsNotNull(toggle, "The PowerRename settings page did not expose its enable switch."); + Assert.IsTrue(toggle!.IsOn, "PowerRename did not start from the deterministic enabled baseline."); + + var folder = CreateTestFolder(); + var fixture = CreateFile(folder, "context-menu.txt"); + + try + { + toggle = SetModuleEnabled(toggle, false); + var explorer = OpenExplorer(folder); + AssertClassicMenuContainsEntry(explorer, new[] { fixture }, expected: false, extendedVerbs: false); + + toggle = SetModuleEnabled(toggle, true); + Assert.IsTrue( + WaitForModernPackageRegistration(timeoutMS: 30_000), + "The PowerRename sparse context-menu package did not register after the module was re-enabled."); + explorer = OpenExplorer(folder, forceHandlerRefresh: true); + AssertClassicMenuContainsEntry(explorer, new[] { fixture }, expected: true, extendedVerbs: false); + AssertModernMenuContainsEntry(explorer, new[] { fixture }, expected: true); + } + finally + { + try + { + SetModuleEnabled(toggle, true); + } + catch (Exception ex) + { + TestContext.WriteLine($"Restoring the PowerRename toggle failed; restarting the scope. {ex.Message}"); + RestartScope(); + } + } + } + + [TestMethod("PowerRename.ContextMenu.ShowIcon")] + [TestCategory("PowerRename")] + public void ContextMenuIconFollowsShowIconSetting() + { + PrepareContextMenuTest(); + + // Checklist item 2. The icon lives in MENUITEMINFO.hbmpItem, which GetMenuItemInfo will not + // hand across a process boundary (every item of Explorer's menu reads back as 0), so the + // assertion is on the pixels of the entry's icon gutter instead. + var folder = CreateTestFolder(); + var fixture = CreateFile(folder, "icon.txt"); + var explorer = OpenExplorer(folder); + + AssertMenuIconSetting(explorer, fixture, ContextMenuSurface.Classic); + if (ModernSurfaceAvailable()) + { + AssertMenuIconSetting(explorer, fixture, ContextMenuSurface.Modern); + } + } + + [TestMethod("PowerRename.ContextMenu.ExtendedOnly")] + [TestCategory("PowerRename")] + public void ContextMenuHonorsExtendedContextMenuOnlySetting() + { + PrepareContextMenuTest(); + + // Checklist item 3 — the entry moves out of the plain classic menu into the extended one. + var folder = CreateTestFolder(); + var fixture = CreateFile(folder, "extended.txt"); + var explorer = OpenExplorer(folder); + + ConfigureModuleSettings(extendedContextMenuOnly: false); + AssertClassicMenuContainsEntry(explorer, new[] { fixture }, expected: true, extendedVerbs: false); + AssertModernMenuContainsEntry(explorer, new[] { fixture }, expected: true); + + ConfigureModuleSettings(extendedContextMenuOnly: true); + AssertClassicMenuContainsEntry(explorer, new[] { fixture }, expected: false, extendedVerbs: false); + AssertClassicMenuContainsEntry(explorer, new[] { fixture }, expected: true, extendedVerbs: true); + } + + [TestMethod("PowerRename.ContextMenu.OpensWindowWithSelection")] + [TestCategory("PowerRename")] + public void InvokingTheContextMenuOpensPowerRenameWithTheSelection() + { + PrepareContextMenuTest(); + + // The real user path: Explorer streams the selection to the UI over a pipe, not on argv. + var folder = CreateTestFolder(); + var first = CreateFile(folder, "alpha.txt"); + var second = CreateFile(folder, "beta.txt"); + var explorer = OpenExplorer(folder); + + var menu = OpenMenuWithRetry( + explorer, + new[] { first, second }, + ModernSurfaceAvailable() ? ContextMenuSurface.Modern : ContextMenuSurface.Classic, + extendedVerbs: false, + requireEntry: true); + var entry = FindVisibleMenuItem(menu, ContextMenuCaption, timeoutMS: MenuSurfaceTimeoutMS); + Assert.IsNotNull(entry, $"Explorer did not offer '{ContextMenuCaption}' for the selected files."); + Step($"Invoking '{ContextMenuCaption}'"); + entry!.Invoke(msPostAction: 500); + + var window = WindowsFinder.WaitForWindowByApp( + PowerRenameProcessName, + info => info.Width > 0 && info.Height > 0, + timeoutMS: WindowTimeoutMS); + Assert.IsNotNull(window, "The PowerRename window did not open from the context menu."); + + var session = Session.FromProcess(PowerRenameProcessName, PowerToysModule.PowerRename, timeoutMS: WindowTimeoutMS); + Assert.IsTrue( + session.WaitFor( + () => session.Has(By.AccessibilityId(SearchBoxAutomationId), timeoutMS: 1_000), + WindowTimeoutMS, + pollIntervalMS: 500), + "The PowerRename window opened from the context menu but never became ready."); + + Assert.IsTrue( + session.WaitFor( + () => FindRowCheckBox(session, "alpha.txt", 500) is not null && FindRowCheckBox(session, "beta.txt", 500) is not null, + timeoutMS: PreviewTimeoutMS, + pollIntervalMS: 250), + "The PowerRename window did not list both selected files."); + } + + // ---- settings navigation -------------------------------------------------------------------- + + private static Session NavigateToPowerRenameSettings() + { + var settings = Session.FromProcess("PowerToys.Settings", PowerToysModule.PowerToysSettings, timeoutMS: 15_000); + if (WaitForElement(settings, By.AccessibilityId("PowerRenameNavItem"), timeoutMS: 5_000) == false) + { + settings.Find(By.AccessibilityId("FileManagementNavItem")).Click(msPostAction: 500); + Assert.IsTrue( + WaitForElement(settings, By.AccessibilityId("PowerRenameNavItem"), timeoutMS: 10_000), + "The File Management navigation group did not expose PowerRename."); + } + + settings.Find(By.AccessibilityId("PowerRenameNavItem")).Click(msPostAction: 500); + Assert.IsTrue( + WaitForElement(settings, By.AccessibilityId("PowerRenameToggleAutoComplete"), timeoutMS: 60_000) || + WaitForElement(settings, By.Name(ModuleToggleName), timeoutMS: 10_000), + "The PowerRename settings page did not become ready."); + return settings; + } + + private static bool WaitForElement(Session session, By by, int timeoutMS) => + session.WaitFor(() => session.Has(by, timeoutMS: 500), timeoutMS: timeoutMS, pollIntervalMS: 200); + + private static ToggleSwitch SetModuleEnabled(ToggleSwitch toggle, bool enabled) + { + for (var attempt = 1; attempt <= 2; attempt++) + { + try + { + toggle.Toggle(enabled); + if (!toggle.WaitForProperty("ToggleState", enabled ? "On" : "Off", timeoutMS: 5_000)) + { + throw new TimeoutException($"The PowerRename enable switch did not settle to {(enabled ? "On" : "Off")}."); + } + + if (!WaitForModuleEnabledSetting(enabled, timeoutMS: 15_000)) + { + throw new TimeoutException($"settings.json did not persist enabled.PowerRename={enabled}."); + } + + return toggle; + } + catch (TimeoutException) when (attempt < 2) + { + var settings = Session.FromProcess("PowerToys.Settings", PowerToysModule.PowerToysSettings, timeoutMS: 15_000); + toggle = settings.Find(By.Name(ModuleToggleName), timeoutMS: 15_000); + } + } + + return toggle; + } + + private static bool WaitForModuleEnabledSetting(bool expected, int timeoutMS) => + WaitHelper.WaitForStable( + observe: ReadModuleEnabledSetting, + isMatch: enabled => enabled == expected, + timeoutMS: timeoutMS, + requiredConsecutiveMatches: 2, + pollIntervalMS: 250).Succeeded; + + private static bool? ReadModuleEnabledSetting() + { + try + { + var path = Path.Combine(SettingsConfigHelper.PowerToysSettingsRoot, "settings.json"); + var root = JsonNode.Parse(File.ReadAllText(path)); + return root?["enabled"]?["PowerRename"]?.GetValue(); + } + catch + { + return null; + } + } + + private static bool WaitForModernPackageRegistration(int timeoutMS) + { + if (!IsWindows11OrNewer) + { + return true; + } + + return WaitHelper.WaitForStable( + observe: ModernPackageRegistered, + isMatch: registered => registered, + timeoutMS: timeoutMS, + requiredConsecutiveMatches: 2, + pollIntervalMS: 250).Succeeded; + } + + private static bool ModernPackageRegistered() + { + try + { + return new Windows.Management.Deployment.PackageManager() + .FindPackagesForUser(string.Empty) + .Any(package => package.Id.Name.Contains(ModernPackageName, StringComparison.OrdinalIgnoreCase)); + } + catch + { + return false; + } + } + + // ---- context-menu assertions ----------------------------------------------------------------- + + private enum ContextMenuSurface + { + /// The Windows 11 tier-1 (sparse-MSIX, IExplorerCommand) menu. + Modern, + + /// The classic #32768 menu, reached through "Show more options" on Windows 11. + Classic, + } + + /// Whether this OS/build actually shows the tier-1 surface. + private static bool ModernSurfaceAvailable() => IsWindows11OrNewer && ModernPackageRegistered(); + + private void AssertModernMenuContainsEntry(Session explorer, string[] paths, bool expected) + { + // The tier-1 surface only exists on Windows 11, and only once its sparse package registered — + // which needs a signed build. Skipping it on an unsigned build keeps the classic assertions + // meaningful instead of failing every Windows 11 run. + if (!ModernSurfaceAvailable()) + { + Step("Skipping the tier-1 context menu: its sparse package is not registered on this build."); + return; + } + + var menu = OpenMenuWithRetry(explorer, paths, ContextMenuSurface.Modern, extendedVerbs: false, requireEntry: expected); + try + { + var observation = WaitHelper.WaitForStable( + observe: () => FindVisibleMenuItem(menu, ContextMenuCaption, timeoutMS: 250) is not null, + isMatch: present => present == expected, + timeoutMS: 5_000, + requiredConsecutiveMatches: expected ? 2 : 8, + pollIntervalMS: 250); + Assert.IsTrue( + observation.Succeeded, + $"The tier-1 Explorer context menu did {(expected ? "not show" : "show")} '{ContextMenuCaption}'."); + } + finally + { + KeyboardHelper.SendKeys(Key.Esc); + } + } + + private void AssertClassicMenuContainsEntry(Session explorer, string[] paths, bool expected, bool extendedVerbs) + { + var menu = OpenMenuWithRetry(explorer, paths, ContextMenuSurface.Classic, extendedVerbs, requireEntry: expected); + try + { + var observation = WaitHelper.WaitForStable( + observe: () => ClassicContextMenu.TryReadItemCaptions(new IntPtr(menu.WindowHandle)), + isMatch: captions => captions is not null && HasEntry(captions) == expected, + timeoutMS: 5_000, + requiredConsecutiveMatches: expected ? 2 : 8, + pollIntervalMS: 250); + Assert.IsTrue( + observation.Succeeded, + $"The classic Explorer context menu ({(extendedVerbs ? "extended" : "plain")}) did " + + $"{(expected ? "not show" : "show")} '{ContextMenuCaption}'. {Describe(observation.LastObservation)}"); + } + finally + { + KeyboardHelper.SendKeys(Key.Esc); + } + } + + private static bool HasEntry(IReadOnlyList? captions) => + captions?.Any(caption => caption.Equals(ContextMenuCaption, StringComparison.OrdinalIgnoreCase)) == true; + + private static string Describe(IReadOnlyList? captions) => + captions is null + ? "Menu items: ." + : $"Menu items: [{string.Join(", ", captions.Select(caption => $"'{caption}'"))}]."; + + /// + /// Crop of the PowerRename entry's row, taken from the live desktop so the popup menu (which no + /// window-scoped capture reaches) is included. + /// + private void AssertMenuIconSetting(Session explorer, string fixture, ContextMenuSurface surface) + { + var surfaceName = surface.ToString().ToLowerInvariant(); + var withIcon = CaptureMenuEntry(explorer, fixture, showIcon: true, $"{surfaceName}-icon-on", surface); + var withoutIcon = CaptureMenuEntry(explorer, fixture, showIcon: false, $"{surfaceName}-icon-off", surface); + + var iconPixels = CountGutterDetailPixels(withIcon); + var plainPixels = CountGutterDetailPixels(withoutIcon); + Step($"{surface} icon gutter detail pixels: on={iconPixels}, off={plainPixels}"); + + Assert.IsTrue( + plainPixels < 8, + $"The {surface} entry's icon gutter had {plainPixels} non-background pixels while the icon setting was off."); + Assert.IsTrue( + iconPixels > plainPixels + 20, + $"The {surface} entry showed no icon difference (on={iconPixels}, off={plainPixels} non-background pixels)."); + } + + private string CaptureMenuEntry(Session explorer, string fixture, bool showIcon, string name, ContextMenuSurface surface) + { + ConfigureModuleSettings(showIcon: showIcon); + var menu = OpenMenuWithRetry(explorer, new[] { fixture }, surface, extendedVerbs: false, requireEntry: true); + var results = TestContext.TestResultsDirectory ?? Path.GetTempPath(); + var desktopPath = Path.Combine(results, $"context-menu-{name}-desktop.png"); + var entryPath = Path.Combine(results, $"context-menu-{name}.png"); + + try + { + var entry = FindVisibleMenuItem(menu, ContextMenuCaption, timeoutMS: MenuSurfaceTimeoutMS); + Assert.IsNotNull( + entry, + $"The {surface} Explorer context menu did not show '{ContextMenuCaption}'. " + + (surface == ContextMenuSurface.Classic + ? Describe(ClassicContextMenu.TryReadItemCaptions(new IntPtr(menu.WindowHandle))) + : string.Empty)); + Assert.IsTrue(ScreenCapture.TryCaptureDesktop(desktopPath), "The desktop could not be captured while the menu was open."); + + using (var desktop = new Bitmap(desktopPath)) + { + var bounds = Rectangle.Intersect( + new Rectangle(entry!.X, entry.Y, entry.Width, entry.Height), + new Rectangle(0, 0, desktop.Width, desktop.Height)); + Assert.IsTrue( + bounds.Width > 0 && bounds.Height > 0, + $"The '{ContextMenuCaption}' entry reported an off-screen rectangle " + + $"({entry.X},{entry.Y},{entry.Width},{entry.Height}) on a {desktop.Width}x{desktop.Height} desktop."); + + using var crop = desktop.Clone(bounds, desktop.PixelFormat); + crop.Save(entryPath, System.Drawing.Imaging.ImageFormat.Png); + } + + TestContext.AddResultFile(entryPath); + return entryPath; + } + finally + { + KeyboardHelper.SendKeys(Key.Esc); + if (File.Exists(desktopPath)) + { + File.Delete(desktopPath); + } + } + } + + /// + /// Pixels in the entry's icon gutter that differ from the gutter's dominant (background) colour. + /// An entry with an icon paints tens of them; an entry without paints none. + /// + private static int CountGutterDetailPixels(string imagePath) + { + using var image = new Bitmap(imagePath); + var gutterWidth = Math.Max(1, Math.Min(24, image.Width / 3)); + var counts = new Dictionary(); + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < gutterWidth; x++) + { + var key = image.GetPixel(x, y).ToArgb(); + counts[key] = counts.TryGetValue(key, out var seen) ? seen + 1 : 1; + } + } + + var background = Color.FromArgb(counts.OrderByDescending(pair => pair.Value).First().Key); + var detail = 0; + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < gutterWidth; x++) + { + var pixel = image.GetPixel(x, y); + if (Math.Abs(pixel.R - background.R) > 30 || + Math.Abs(pixel.G - background.G) > 30 || + Math.Abs(pixel.B - background.B) > 30) + { + detail++; + } + } + } + + return detail; + } + + /// + /// Open the requested context-menu surface, re-establishing the Explorer selection before every + /// attempt and reopening a stale window: a slow agent re-renders the file view asynchronously and + /// silently drops the selection the gesture needs. + /// + /// + /// When the entry is expected, treat a menu that opened without it as a failed attempt and reopen. + /// A shell extension's item can be enumerated after the popup is already on screen, so one open is + /// not enough to conclude the entry is missing. + /// + private Session OpenMenuWithRetry(Session explorer, string[] paths, ContextMenuSurface surface, bool extendedVerbs, bool requireEntry = false) + { + var folder = Path.GetDirectoryName(paths[0])!; + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(MenuAttemptTimeoutMS); + var selectionFailures = 0; + + do + { + KeyboardHelper.SendKeys(Key.Esc); + var selected = TrySelectStable(explorer, paths); + if (selected is null) + { + if (++selectionFailures >= 2) + { + selectionFailures = 0; + explorer = OpenExplorer(folder); + } + + Thread.Sleep(300); + continue; + } + + selectionFailures = 0; + explorer = selected; + + var menu = surface == ContextMenuSurface.Classic + ? OpenClassicMenu(explorer, extendedVerbs) + : OpenModernMenu(explorer); + if (menu is not null && (!requireEntry || MenuHasEntry(menu, surface))) + { + return menu; + } + + Thread.Sleep(300); + } + while (DateTime.UtcNow < deadline); + + Assert.Fail( + $"Explorer never opened the {surface} context menu" + + $"{(requireEntry ? $" carrying '{ContextMenuCaption}'" : string.Empty)} " + + $"for [{string.Join(", ", paths.Select(Path.GetFileName))}]. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + return null!; + } + + private static bool MenuHasEntry(Session menu, ContextMenuSurface surface) => + surface == ContextMenuSurface.Classic + ? HasEntry(ClassicContextMenu.TryReadItemCaptions(new IntPtr(menu.WindowHandle))) + : FindVisibleMenuItem(menu, ContextMenuCaption, timeoutMS: 5_000) is not null; + + private Session? OpenModernMenu(Session explorer) + { + TryEnsureExplorerForeground(explorer); + Step("Opening the tier-1 Explorer context menu"); + if (!WindowControl.TryOpenContextMenuForFocusedControl(new IntPtr(explorer.WindowHandle))) + { + return null; + } + + return WaitForMenuWindow(ModernContextMenuClassName, MenuSurfaceTimeoutMS); + } + + /// + /// Reach the classic menu on either OS. Windows 11 shows the tier-1 menu first, so the classic one + /// is one "Show more options" away — unless Shift is held, which takes the shell straight there. + /// + private Session? OpenClassicMenu(Session explorer, bool extendedVerbs) + { + TryEnsureExplorerForeground(explorer); + Step($"Opening the classic Explorer context menu (extended verbs: {extendedVerbs})"); + + // Keep any Shift hold short: Windows pops its Filter Keys prompt after eight seconds. + if (extendedVerbs) + { + KeyboardHelper.PressKey(Key.LShift); + + // Injected key input is queued behind posted messages, so give Explorer a moment to see + // Shift before the context-menu request makes it build the menu. + Thread.Sleep(300); + } + + try + { + if (!WindowControl.TryOpenContextMenuForFocusedControl(new IntPtr(explorer.WindowHandle))) + { + return null; + } + + var surface = WaitForMenuWindow( + new[] { ClassicContextMenu.WindowClassName, ModernContextMenuClassName }, + extendedVerbs ? 4_000 : MenuSurfaceTimeoutMS); + if (surface is null) + { + return null; + } + + if (IsClassicMenuWindow(surface)) + { + return surface; + } + + var showMore = FindVisibleMenuItem(surface, ShowMoreOptionsCaption, timeoutMS: extendedVerbs ? 2_000 : 8_000); + if (showMore is null) + { + return null; + } + + try + { + showMore.Invoke(msPostAction: 300); + } + catch (Exception) + { + // The popup can vanish between the find and the invoke; let the caller reopen it. + return null; + } + + return WaitForMenuWindow(ClassicContextMenu.WindowClassName, extendedVerbs ? 3_000 : MenuSurfaceTimeoutMS); + } + finally + { + if (extendedVerbs) + { + KeyboardHelper.ReleaseKey(Key.LShift); + } + } + } + + private static bool IsClassicMenuWindow(Session menu) => + WindowsFinder.ListAll().Any(window => + window.Hwnd == menu.WindowHandle && + window.ClassName.Equals(ClassicContextMenu.WindowClassName, StringComparison.OrdinalIgnoreCase)); + + private static Session? WaitForMenuWindow(string className, int timeoutMS) => + WaitForMenuWindow(new[] { className }, timeoutMS); + + private static Session? WaitForMenuWindow(IReadOnlyList classNames, int timeoutMS) => + WindowsFinder.WaitForWindow( + window => classNames.Any(name => name.Equals(ClassicContextMenu.WindowClassName, StringComparison.OrdinalIgnoreCase) + ? window.ClassName.Equals(name, StringComparison.OrdinalIgnoreCase) + : window.ClassName.Contains(name, StringComparison.OrdinalIgnoreCase)), + timeoutMS: timeoutMS, + pollIntervalMS: 100); + + private static Element? FindVisibleMenuItem(Session menu, string name, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + try + { + var item = menu.FindAll(By.Name(name), timeoutMS: 250) + .FirstOrDefault(element => + element.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && + element.ControlType.Equals("MenuItem", StringComparison.OrdinalIgnoreCase) && + element.Width > 0 && + element.Height > 0 && + element.Displayed); + if (item is not null) + { + return item; + } + } + catch (Exception) + { + // A transient popup can disappear mid-query; keep polling until the deadline. + } + + Thread.Sleep(100); + } + while (DateTime.UtcNow < deadline); + + return null; + } + + // ---- Explorer -------------------------------------------------------------------------------- + + private void PrepareContextMenuTest() + { + contextMenuTest = true; + Assert.IsTrue(CloseExplorerFileWindows(), "Stale Explorer file windows could not be closed before the test."); + } + + private Session OpenExplorer(string folderPath, bool forceHandlerRefresh = false) + { + EnsureContextMenuHandlersLoaded(forceHandlerRefresh); + CloseExplorerFileWindows(); + var existing = WindowsFinder.ListByApp(ExplorerProcessName) + .Where(IsExplorerFileWindow) + .Select(window => window.Hwnd) + .ToHashSet(); + + Step($"Opening Explorer at '{folderPath}'"); + using (Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/n,\"{folderPath}\"", + UseShellExecute = true, + })) + { + } + + var explorer = WindowsFinder.WaitForWindowByApp( + ExplorerProcessName, + window => IsExplorerFileWindow(window) && !existing.Contains(window.Hwnd), + timeoutMS: ExplorerTimeoutMS); + Assert.IsNotNull(explorer, $"Explorer did not open '{folderPath}'."); + + TryEnsureExplorerForeground(explorer!); + return explorer!; + } + + /// + /// Both handlers register when the module is enabled — the classic registry-COM one always, plus + /// the sparse MSIX package on signed builds. An Explorer that was already running only picks them + /// up after the shell restarts, so restart it once per class. + /// + private static void EnsureContextMenuHandlersLoaded(bool force) + { + if (explorerRefreshedForRegistration && !force) + { + return; + } + + explorerRefreshedForRegistration = true; + Thread.Sleep(3_000); + + var previous = Process.GetProcessesByName(ExplorerProcessName) + .Select(process => + { + var id = process.Id; + process.Dispose(); + return id; + }) + .ToHashSet(); + + // Only explorer.exe: killing its tree would also stop processes the user launched from it. + WindowControl.TryKillProcessByName(ExplorerProcessName); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (DateTime.UtcNow < deadline) + { + var current = Process.GetProcessesByName(ExplorerProcessName); + var fresh = current.Any(process => !previous.Contains(process.Id)); + foreach (var process in current) + { + process.Dispose(); + } + + if (fresh) + { + break; + } + + Thread.Sleep(500); + } + + Thread.Sleep(2_000); + } + + private static Session? TrySelectStable(Session explorer, string[] paths) + { + if (ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), paths, paths[0], timeoutMS: 12_000, requiredConsecutiveMatches: 4).Succeeded) + { + return explorer; + } + + var replacement = FindReplacementExplorer(explorer, Path.GetDirectoryName(paths[0])!); + if (replacement is not null && + ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(replacement.WindowHandle), paths, paths[0], timeoutMS: 12_000, requiredConsecutiveMatches: 4).Succeeded) + { + return replacement; + } + + return null; + } + + private static Session? FindReplacementExplorer(Session explorer, string folderPath) + { + var folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(folderPath)); + var foreground = WindowControl.GetForegroundWindowHandle().ToInt64(); + var replacement = WindowsFinder.ListByApp(ExplorerProcessName) + .Where(IsExplorerFileWindow) + .Where(window => window.Hwnd != explorer.WindowHandle) + .Where(window => window.Title.Contains(folderName, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(window => window.Hwnd == foreground) + .FirstOrDefault(); + return replacement is null + ? null + : WindowsFinder.WaitForWindow(window => window.Hwnd == replacement.Hwnd, timeoutMS: 2_000, pollIntervalMS: 100); + } + + private void TryEnsureExplorerForeground(Session explorer) + { + if (!WindowControl.WaitForForeground(new IntPtr(explorer.WindowHandle), ExplorerTimeoutMS, requiredConsecutiveMatches: 3)) + { + Step( + $"Explorer HWND {explorer.WindowHandle} did not become stable foreground; continuing. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + } + } + + private static bool IsExplorerFileWindow(WindowsFinder.WindowInfo window) => + window.ClassName.Equals("CabinetWClass", StringComparison.OrdinalIgnoreCase); + + private static bool CloseExplorerFileWindows() => + WindowControl.TryCloseByApp(ExplorerProcessName, IsExplorerFileWindow, timeoutMS: 10_000); +} diff --git a/src/modules/powerrename/PowerRename.UITests.Next/PowerRenameFileListTests.cs b/src/modules/powerrename/PowerRename.UITests.Next/PowerRenameFileListTests.cs new file mode 100644 index 0000000000..2d110f0af1 --- /dev/null +++ b/src/modules/powerrename/PowerRename.UITests.Next/PowerRenameFileListTests.cs @@ -0,0 +1,117 @@ +// 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 Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.PowerRename.UITests; + +/// +/// The preview list itself: per-item inclusion, the renamed-only filter, and the header +/// select/deselect-all control. +/// +/// +/// Covers checklist items 16-18 of microsoft/PowerToys#40663. The modern PowerRename window replaced +/// the clickable "Original"/"Renamed" column headers of the original checklist with a Filter flyout +/// and a header checkbox, so those two items are driven through their current surfaces. +/// +public sealed partial class PowerRenameTests +{ + private const string FilterButtonAutomationId = "FilterButton"; + private const string ShowAllFilesAutomationId = "button_showAll"; + private const string ShowOnlyRenamedAutomationId = "button_showRenamed"; + private const string SelectAllCheckBoxAutomationId = "checkBox_selectAll"; + + [TestMethod("PowerRename.FileList.UncheckExcludesItem")] + [TestCategory("PowerRename")] + public void UncheckedItemsAreExcludedFromTheRename() + { + // Checklist item 16. + var folder = CreateTestFolder(); + var first = CreateFile(folder, "one.txt"); + var second = CreateFile(folder, "two.txt"); + var window = LaunchPowerRename(first, second); + + WaitForOriginalCount(window, 2); + SetSearchText(window, "o"); + SetReplaceText(window, "0"); + WaitForPreviewName(window, "0ne.txt"); + WaitForPreviewName(window, "tw0.txt"); + WaitForRenamedCount(window, 2); + + SetRowChecked(window, "two.txt", false); + WaitForRenamedCount(window, 1); + + ApplyRenameAndAssertEntries(window, folder, "0ne.txt", "two.txt"); + } + + [TestMethod("PowerRename.FileList.FilterRenamedOnly")] + [TestCategory("PowerRename")] + public void FilterCanShowOnlyItemsThatWillBeRenamed() + { + // Checklist item 17. + var folder = CreateTestFolder(); + var renamed = CreateFile(folder, "match.txt"); + var untouched = CreateFile(folder, "other.txt"); + var window = LaunchPowerRename(renamed, untouched); + + SetSearchText(window, "match"); + SetReplaceText(window, "hit"); + WaitForPreviewName(window, "hit.txt"); + + SelectFilter(window, ShowOnlyRenamedAutomationId); + Assert.IsTrue( + window.WaitFor( + () => FindRowCheckBox(window, "other.txt", timeoutMS: 500) is null, + timeoutMS: PreviewTimeoutMS, + pollIntervalMS: 250), + "The renamed-only filter still listed 'other.txt'."); + Assert.IsNotNull( + FindRowCheckBox(window, "match.txt", PreviewTimeoutMS), + "The renamed-only filter dropped the item that will be renamed."); + + SelectFilter(window, ShowAllFilesAutomationId); + Assert.IsTrue( + window.WaitFor( + () => FindRowCheckBox(window, "other.txt", timeoutMS: 500) is not null, + timeoutMS: PreviewTimeoutMS, + pollIntervalMS: 250), + "Switching back to 'Show all files' did not restore the unmatched item."); + } + + [TestMethod("PowerRename.FileList.SelectDeselectAll")] + [TestCategory("PowerRename")] + public void HeaderCheckBoxSelectsAndDeselectsEveryItem() + { + // Checklist item 18. + var folder = CreateTestFolder(); + var first = CreateFile(folder, "one.txt"); + var second = CreateFile(folder, "two.txt"); + var window = LaunchPowerRename(first, second); + + SetSearchText(window, "o"); + SetReplaceText(window, "0"); + WaitForRenamedCount(window, 2); + + SetOptionCheckBox(window, SelectAllCheckBoxAutomationId, false); + WaitForRenamedCount(window, 0); + Assert.IsFalse(FindRowCheckBox(window, "one.txt", PreviewTimeoutMS)!.IsChecked, "'one.txt' stayed selected."); + Assert.IsFalse(FindRowCheckBox(window, "two.txt", PreviewTimeoutMS)!.IsChecked, "'two.txt' stayed selected."); + + SetOptionCheckBox(window, SelectAllCheckBoxAutomationId, true); + WaitForRenamedCount(window, 2); + Assert.IsTrue(FindRowCheckBox(window, "one.txt", PreviewTimeoutMS)!.IsChecked, "'one.txt' was not re-selected."); + Assert.IsTrue(FindRowCheckBox(window, "two.txt", PreviewTimeoutMS)!.IsChecked, "'two.txt' was not re-selected."); + } + + /// Pick an entry of the Filter flyout, which the window hosts in its own popup. + private void SelectFilter(Session window, string itemAutomationId) + { + Step($"Selecting filter '{itemAutomationId}'"); + window.Find