mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
[UITests][PowerRename] Migrate to new .Next and add more UI tests (#50096)
## Summary of the Pull Request Adds a `PowerRename.UITests.Next` suite powered by winappcli and automates all 18 scenarios from #40663. The suite covers PowerRename settings, search and replace behavior, regular expressions, formatting and filtering options, file-list interactions, and both classic and Windows 11 context-menu workflows. The PR also stabilizes shared `UITestAutomation.Next` runner lifetimes and settings restoration, adds automation IDs for the original and renamed counters, and prepares unsigned CI builds for PowerRename shell testing. CI now signs the sparse context-menu MSIX and the runner/Settings IPC companions with a disposable machine-trusted test identity. ## PR Checklist - [x] Closes: #40663 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [x] **New binaries:** Not applicable; no new shipped product binaries are added - [x] JSON for signing: Not applicable - [x] WXS for installer: Not applicable - [x] YML for CI pipeline: The new UI-test project is discovered through the existing `*UITest*.csproj` pipeline flow and is registered in `PowerToys.slnx` - [x] YML for signed pipeline: Not applicable - [x] **Documentation updated:** Not applicable; there are no user-facing behavior or documentation changes ## Detailed Description of the Pull Request / Additional comments ### PowerRename UI tests - Adds a 25-case `PowerRename.UITests.Next` executable covering all 18 checklist items from #40663. - Exercises classic context-menu registration on Windows 10 and Windows 11. - Exercises the signed Windows 11 tier-1 context menu, including icon visibility and real invocation with an Explorer selection. - Covers search/replace preview and application, text formatting, file/folder/subfolder inclusion, filename/extension scope, enumeration, case sensitivity, match-all behavior, regular expressions, file timestamps, Boost syntax, MRU autocomplete, persisted values, and file-list selection/filtering. - Preserves the existing legacy tests. ### Test reliability and automation hooks - Reuses one runner/Settings lifetime across the complete PowerRename suite to avoid repeated cold launches on constrained agents. - Retains and restores global settings for the full class lifetime and verifies that both Settings and the runner remain healthy. - Propagates scope and PowerRename cleanup failures instead of silently leaking process or profile state. - Adds `OriginalCount` and `RenamedCount` automation IDs. These are automation-only metadata and do not change the visible UI. - Uses stable preview samples, exact count targeting, authoritative Explorer selection, readable classic-menu inventories, and live UIA visibility for popup items. ### Unsigned CI build support - Extends the existing sparse-package test signer with required Authenticode companion files. - PowerRename jobs sign `PowerToys.exe` and `PowerToys.Settings.exe` with the same disposable machine-trusted test identity used for sparse MSIX packages. - This preserves Release IPC authentication while allowing Settings module-toggle commands to work on unsigned PR builds. - Windows 11 and ARM64 PowerRename jobs require a validly signed `PowerRenameContextMenuPackage.msix` before tests start. ## Validation Steps Performed ### Builds and discovery - `PowerRename.UITests.Next` Debug x64 build: passed - `PowerRename.UITests.Next` Debug ARM64 cross-build: passed - `PowerRenameUI` Release x64 build: passed - `UITestAutomation.Next.UnitTests` Debug x64 build: passed - `UITestAutomation.Next.UnitTests`: 16/16 passed - Microsoft.Testing.Platform discovery: 25 unique PowerRename test cases ### Local VM matrix All runs used the complete unfiltered `TestCategory=PowerRename` suite and restored the standard user's settings file byte-for-byte. | Guest | Profile | Result | |---|---|---:| | Windows 10 x64 | Default, 4 vCPU / 8 GB | 25/25 | | Windows 10 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | | Windows 11 x64 | Default, 4 vCPU / 8 GB | 25/25 | | Windows 11 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | ### Azure DevOps UI Test Automation - Final build: [155646235 / 20260824.1](https://dev.azure.com/microsoft/Dart/_build/results?buildId=155646235) - Source revision: `4496683104aea622b30179909bd6e95a17d5500f` - ARM64: 25/25 passed - Windows 10 x64: 25/25 passed - Windows 11 x64: 25/25 passed - Total: 75/75 passed, with zero failed, skipped, not-executed, or unanalyzed results - ARM64 and x64 Release product builds succeeded and published their normal artifacts - Signing steps verified the PowerRename sparse MSIX where applicable and both IPC companion executables on every PowerRename test job <img width="372" height="314" alt="image" src="https://github.com/user-attachments/assets/bc303673-0325-4f85-8d70-ef93110baf5b" />
This commit is contained in:
@@ -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
|
**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
|
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
|
and complete machine/per-user install roots. Windows 11/ARM64 Image Resizer, PowerRename, and
|
||||||
`-RequiredPackage ImageResizerContextMenuPackage.msix`, so missing, unsigned, or untrusted setup fails
|
all-module jobs pass their context-menu MSIX names through `-RequiredPackage`, so missing, unsigned,
|
||||||
at the prerequisite instead of surfacing later as a product-test failure. Jobs that do not exercise
|
or untrusted setup fails at the prerequisite instead of surfacing later as a product-test failure.
|
||||||
Image Resizer keep signing best-effort because their suites can guard unavailable modern packages:
|
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
|
```yaml
|
||||||
- pwsh: |
|
- pwsh: |
|
||||||
@@ -76,20 +86,38 @@ Image Resizer keep signing best-effort because their suites can guard unavailabl
|
|||||||
"$(Pipeline.Workspace)\$(TestArtifactsName)",
|
"$(Pipeline.Workspace)\$(TestArtifactsName)",
|
||||||
"$env:ProgramFiles\PowerToys",
|
"$env:ProgramFiles\PowerToys",
|
||||||
"$env:LOCALAPPDATA\PowerToys")
|
"$env:LOCALAPPDATA\PowerToys")
|
||||||
if ($requiresImageResizer) {
|
# Build the platform/module-specific arrays as shown in job-test-project.yml.
|
||||||
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" `
|
$requiredPackages = @('PowerRenameContextMenuPackage.msix')
|
||||||
-PackageRoot $roots -RequiredPackage 'ImageResizerContextMenuPackage.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 {
|
} 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)" }
|
catch { Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)" }
|
||||||
}
|
}
|
||||||
displayName: "Sign sparse MSIX packages (test trust)"
|
displayName: "Sign sparse MSIX packages (test trust)"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prerequisite:** `signtool.exe`. The script finds it across PATH, any `Windows Kits` install (all
|
**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
|
versions, plus the App Certification Kit). As a fallback it verifies and freshly extracts the exact
|
||||||
resort it fetches the public `Microsoft.Windows.SDK.BuildTools` package from nuget.org, so an agent
|
repository-pinned `Microsoft.Windows.SDK.BuildTools` `.nupkg` from the NuGet cache, or downloads that
|
||||||
without the SDK still works given outbound access. Verified end-to-end in a local Win11
|
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
|
VM — the unsigned package fails `Add-AppxPackage` / `AddPackageByUriAsync` with `0x800B0100`, and
|
||||||
after `signSparsePackages.ps1` signs it and the cert is force-trusted (`LocalMachine\Root` +
|
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`.
|
`TrustedPeople`) the same registration succeeds and the package appears in `Get-AppxPackage`.
|
||||||
|
|||||||
26
.github/skills/ui-tests-pipeline-ci/SKILL.md
vendored
26
.github/skills/ui-tests-pipeline-ci/SKILL.md
vendored
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: ui-tests-pipeline-ci
|
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
|
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:
|
Use this skill when an authorized Microsoft FTE asks to:
|
||||||
|
|
||||||
- Queue PowerToys UITests in the internal `UI Test Automation` pipeline.
|
- 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.
|
- Monitor a UITests pipeline run or summarize its stages, tests, and artifacts.
|
||||||
- Iterate on a failure that passed the complete local VM matrix.
|
- 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.
|
- 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
|
## 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
|
the default and `Constrained` profiles for Windows 10 and Windows 11, plus the applicable
|
||||||
architecture builds/guests required by `ui-tests-local-vm`.
|
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.
|
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
|
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.
|
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]`.
|
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
|
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.
|
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.
|
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
|
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,
|
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
|
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` |
|
| Project | `Dart` |
|
||||||
| Pipeline name | `UI Test Automation` |
|
| Pipeline name | `UI Test Automation` |
|
||||||
| Current known definition ID | `161438` (discover by name each session; do not blindly hardcode) |
|
| 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` |
|
| Platforms | `arm64`, `x64` |
|
||||||
| Default booleans | `enableMsBuildCaching=false`, `useVSPreview=false`, `useLatestWebView2=false` |
|
| 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
|
Read and execute [references/agentic-loop.md](./references/agentic-loop.md) from top to bottom. It
|
||||||
contains:
|
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).
|
[REST helper](./scripts/AzureDevOps.ps1).
|
||||||
- Local-signoff and active-run preflight.
|
- Local-signoff and active-run preflight.
|
||||||
- `buildNow` versus `specificBuildId` decision rules.
|
- `buildNow` versus `specificBuildId` decision rules.
|
||||||
|
|||||||
@@ -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
|
builds, timelines, logs, preview/queue, stage retry/cancel, test results, artifacts, and result
|
||||||
attachments.
|
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/<branch> `
|
||||||
|
-ProbeModule <Module.UITests> `
|
||||||
|
-ProbeBuildId <KNOWN_COMPLETED_BUILD_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
```pwsh
|
||||||
. .\.github\skills\ui-tests-pipeline-ci\scripts\AzureDevOps.ps1
|
. .\.github\skills\ui-tests-pipeline-ci\scripts\AzureDevOps.ps1
|
||||||
Test-AzDevOpsSession
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The helper obtains a token for Azure DevOps resource
|
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
|
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
|
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
|
the preflight fails, ask the user to resolve its exact failed check and stop with an access blocker.
|
||||||
blocker. Do not fall back to another transport.
|
Do not fall back to another transport.
|
||||||
|
|
||||||
`Invoke-AzDevOpsRest` accepts a project-relative REST path and returns `{ Body, Headers }`:
|
`Invoke-AzDevOpsRest` accepts a project-relative REST path and returns `{ Body, Headers }`:
|
||||||
|
|
||||||
|
|||||||
54
.github/skills/ui-tests-pipeline-ci/references/setup-preflight.md
vendored
Normal file
54
.github/skills/ui-tests-pipeline-ci/references/setup-preflight.md
vendored
Normal file
@@ -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.
|
||||||
498
.github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1
vendored
Normal file
498
.github/skills/ui-tests-pipeline-ci/scripts/Test-AzureDevOpsSetup.ps1
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
72
.pipelines/removeTestSigningCertificates.ps1
Normal file
72
.pipelines/removeTestSigningCertificates.ps1
Normal file
@@ -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 ', ')."
|
||||||
@@ -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
|
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.
|
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 <c>always()</c> cleanup step to remove the trust anchor
|
||||||
|
and private key, including after an interrupted prior job.
|
||||||
|
|
||||||
.PARAMETER Force
|
.PARAMETER Force
|
||||||
Re-sign even packages that already carry a valid signature.
|
Re-sign even packages that already carry a valid signature.
|
||||||
|
|
||||||
@@ -68,6 +81,15 @@ param(
|
|||||||
[Parameter()]
|
[Parameter()]
|
||||||
[string[]]$RequiredPackage = @(),
|
[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]$Force,
|
||||||
|
|
||||||
[switch]$SkipLocalTrust,
|
[switch]$SkipLocalTrust,
|
||||||
@@ -78,6 +100,7 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$testCertificateFriendlyName = 'PowerToys UI Test Signing'
|
||||||
|
|
||||||
function Select-SignToolByArch {
|
function Select-SignToolByArch {
|
||||||
param([string[]]$Paths)
|
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,
|
# 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 {
|
function Find-SignTool {
|
||||||
$cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
$cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||||
if ($cmd) { return $cmd.Source }
|
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
|
return Select-SignToolByArch -Paths $found
|
||||||
}
|
}
|
||||||
|
|
||||||
# Last resort when the agent has no Windows SDK: fetch signtool from the public
|
# 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 {
|
function Get-SignToolFromNuget {
|
||||||
|
$nupkg = $null
|
||||||
|
$archive = $null
|
||||||
try {
|
try {
|
||||||
$index = Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/index.json' -UseBasicParsing
|
[xml]$centralPackages = Get-Content (Join-Path $PSScriptRoot '..\Directory.Packages.props') -Raw
|
||||||
$version = @($index.versions | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' })[-1]
|
$versionNode = $centralPackages.SelectSingleNode(
|
||||||
if (-not $version) { return $null }
|
"/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"
|
$dest = Join-Path $env:TEMP "pt-sdk-buildtools-$version"
|
||||||
if (-not (Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue)) {
|
$nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.nupkg"
|
||||||
Write-Host "signtool not found on the agent; fetching Windows SDK BuildTools $version from NuGet."
|
$archive = Join-Path $env:TEMP "sdk-buildtools-$version.zip"
|
||||||
$nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.zip"
|
$packageFileName = "microsoft.windows.sdk.buildtools.$version.nupkg"
|
||||||
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/$version/microsoft.windows.sdk.buildtools.$version.nupkg" -OutFile $nupkg -UseBasicParsing
|
$cachedPackage = @($env:NUGET_PACKAGES, (Join-Path $env:USERPROFILE '.nuget\packages')) |
|
||||||
Expand-Archive -Path $nupkg -DestinationPath $dest -Force
|
Where-Object { $_ } |
|
||||||
Remove-Item $nupkg -Force -ErrorAction SilentlyContinue
|
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 |
|
$paths = Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue |
|
||||||
Select-Object -ExpandProperty FullName
|
Select-Object -ExpandProperty FullName
|
||||||
return Select-SignToolByArch -Paths $paths
|
return Select-SignToolByArch -Paths $paths
|
||||||
@@ -152,6 +191,28 @@ function Get-SignToolFromNuget {
|
|||||||
Write-Warning "Could not obtain signtool from NuGet: $($_.Exception.Message)"
|
Write-Warning "Could not obtain signtool from NuGet: $($_.Exception.Message)"
|
||||||
return $null
|
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 <Identity Publisher="..."> straight out of the .msix/.appx (a zip) without extracting it.
|
# Read <Identity Publisher="..."> 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] }
|
if ($certCache.ContainsKey($Subject)) { return $certCache[$Subject] }
|
||||||
|
|
||||||
$cert = Get-ChildItem Cert:\CurrentUser\My |
|
$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
|
Sort-Object NotAfter -Descending | Select-Object -First 1
|
||||||
|
|
||||||
if (-not $cert) {
|
if (-not $cert) {
|
||||||
Write-Host "Creating self-signed test certificate for: $Subject"
|
Write-Host "Creating self-signed test certificate for: $Subject"
|
||||||
$cert = New-SelfSignedCertificate -Subject $Subject `
|
$cert = New-SelfSignedCertificate -Subject $Subject `
|
||||||
-CertStoreLocation Cert:\CurrentUser\My `
|
-CertStoreLocation Cert:\CurrentUser\My `
|
||||||
|
-FriendlyName $testCertificateFriendlyName `
|
||||||
-KeyAlgorithm RSA -KeyLength 2048 `
|
-KeyAlgorithm RSA -KeyLength 2048 `
|
||||||
-Type CodeSigningCert -HashAlgorithm SHA256 `
|
-Type CodeSigningCert -HashAlgorithm SHA256 `
|
||||||
-NotAfter (Get-Date).AddYears(1)
|
-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,
|
# 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
|
# 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.
|
# 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
|
$packages = $packages | Sort-Object FullName -Unique
|
||||||
|
|
||||||
if (-not $packages) {
|
$requiredAuthenticodeFiles = @()
|
||||||
if ($RequiredPackage.Count -gt 0) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 ', ')."
|
throw "No packages found under '$($PackageRoot -join ', ')' while requiring: $($RequiredPackage -join ', ')."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "No packages found under: $($PackageRoot -join ', ')"
|
if (-not $packages -and $requiredAuthenticodeFiles.Count -eq 0) {
|
||||||
|
Write-Host "No packages or required Authenticode files found under: $($PackageRoot -join ', ')"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,9 +397,7 @@ foreach ($pkg in $packages) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $signtool) {
|
if (-not $signtool) {
|
||||||
$signtool = Find-SignTool
|
$signtool = Get-VerifiedSignTool
|
||||||
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.' }
|
|
||||||
Write-Host "Using signtool: $signtool"
|
Write-Host "Using signtool: $signtool"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,4 +429,56 @@ if ($requiredPackages.Count -gt 0) {
|
|||||||
Write-Host "Verified required sparse package(s): $($requiredPackages.FullName -join ', ')"
|
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."
|
Write-Host "Signed $signed package(s) with a trusted test certificate."
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ jobs:
|
|||||||
fetchDepth: 1
|
fetchDepth: 1
|
||||||
fetchTags: false
|
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) }}:
|
- ${{ if eq(parameters.useLatestWebView2, true) }}:
|
||||||
- powershell: |
|
- powershell: |
|
||||||
$edge_url = 'https://go.microsoft.com/fwlink/?linkid=2084649&Channel=Canary&language=en'
|
$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
|
# 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
|
# 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).
|
# 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
|
# Focused and all-module jobs that exercise a modern context menu require the corresponding
|
||||||
# the signed/trusted package; unrelated jobs keep this setup best-effort.
|
# 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: |
|
- pwsh: |
|
||||||
$packageRoots = @(
|
$packageRoots = @(
|
||||||
"$(Pipeline.Workspace)\$(TestArtifactsName)",
|
"$(Pipeline.Workspace)\$(TestArtifactsName)",
|
||||||
"$env:ProgramFiles\PowerToys",
|
"$env:ProgramFiles\PowerToys",
|
||||||
"$env:LOCALAPPDATA\PowerToys")
|
"$env:LOCALAPPDATA\PowerToys")
|
||||||
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
|
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
|
||||||
$requiresImageResizer = '$(TestPlatform)' -ne 'x64Win10' -and (
|
$selectedModules = @($modulesRaw -split ';' | Where-Object { $_ })
|
||||||
[string]::IsNullOrWhiteSpace($modulesRaw) -or
|
$allModules = [string]::IsNullOrWhiteSpace($modulesRaw)
|
||||||
@($modulesRaw -split ';' | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0)
|
$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) {
|
if ($requiresModernContextMenu -and ($allModules -or @($selectedModules | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0)) {
|
||||||
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" `
|
$requiredPackages += 'ImageResizerContextMenuPackage.msix'
|
||||||
-PackageRoot $packageRoots `
|
}
|
||||||
-RequiredPackage '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 {
|
} else {
|
||||||
try {
|
try {
|
||||||
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" -PackageRoot $packageRoots
|
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" `
|
||||||
|
-PackageRoot $packageRoots `
|
||||||
|
-CertificateMarkerPath $certificateMarkerPath
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)"
|
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
|
Get-Process -Name 'WinAppDriver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
displayName: Stop WinAppDriver
|
displayName: Stop WinAppDriver
|
||||||
condition: always()
|
condition: always()
|
||||||
|
|
||||||
|
- pwsh: |
|
||||||
|
& "$(build.sourcesdirectory)\.pipelines\removeTestSigningCertificates.ps1" `
|
||||||
|
-CertificateMarkerPath "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt"
|
||||||
|
displayName: Remove UI-test signing certificates
|
||||||
|
condition: always()
|
||||||
|
|||||||
@@ -973,6 +973,10 @@
|
|||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
</Project>
|
</Project>
|
||||||
|
<Project Path="src/modules/powerrename/PowerRename.UITests.Next/PowerRename.UITests.Next.csproj">
|
||||||
|
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||||
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
|
</Project>
|
||||||
<Project Path="src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj" Id="2151f984-e006-4a9f-92ef-c6dde3dc8413">
|
<Project Path="src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj" Id="2151f984-e006-4a9f-92ef-c6dde3dc8413">
|
||||||
<BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" />
|
<BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" />
|
||||||
<BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" />
|
<BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" />
|
||||||
|
|||||||
@@ -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
|
agentic loop: run one deterministic test, diagnose and fix it, and finally widen to the complete
|
||||||
module suite on both supported Windows versions.
|
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
|
## Before running tests
|
||||||
|
|
||||||
### `.Next` tests
|
### `.Next` tests
|
||||||
|
|||||||
@@ -87,6 +87,24 @@ PowerRename uses WinUI 3 for its user interface. The UI allows users to:
|
|||||||
- Settings panel for configuring rename options
|
- Settings panel for configuring rename options
|
||||||
- Event handling for `SearchReplaceChanged` to update the preview in real-time
|
- 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
|
||||||
|
|
||||||
### Debugging the Context Menu
|
### Debugging the Context Menu
|
||||||
|
|||||||
@@ -99,10 +99,26 @@ public sealed class SessionHelper
|
|||||||
/// <summary>Process name as winappcli's <c>-a</c> flag (and <see cref="Process.GetProcessesByName(string)"/>) accept it.</summary>
|
/// <summary>Process name as winappcli's <c>-a</c> flag (and <see cref="Process.GetProcessesByName(string)"/>) accept it.</summary>
|
||||||
public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope);
|
public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full path to the executable backing <paramref name="scope"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetExecutablePath(PowerToysModule scope) => ModulePaths.ExePathFor(scope);
|
||||||
|
|
||||||
/// <summary>Returns <c>true</c> if at least one process matching <paramref name="scope"/> is running.</summary>
|
/// <summary>Returns <c>true</c> if at least one process matching <paramref name="scope"/> is running.</summary>
|
||||||
public static bool IsRunning(PowerToysModule scope) =>
|
public static bool IsRunning(PowerToysModule scope) =>
|
||||||
Process.GetProcessesByName(GetProcessName(scope)).Length > 0;
|
Process.GetProcessesByName(GetProcessName(scope)).Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsScopeHealthy(PowerToysModule scope) =>
|
||||||
|
IsRunning(scope) &&
|
||||||
|
(scope != PowerToysModule.PowerToysSettings || IsRunning(PowerToysModule.Runner));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure the runner-owned environment for <paramref name="scope"/> is up and has presented a
|
/// Ensure the runner-owned environment for <paramref name="scope"/> is up and has presented a
|
||||||
/// UIA-visible window. Returns <c>false</c> when the target was already running (nothing
|
/// UIA-visible window. Returns <c>false</c> 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
|
// 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
|
// 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.
|
// a genuinely-dead fresh launch or an orphaned Settings window whose runner has exited.
|
||||||
var alreadyRunning = IsRunning(scope);
|
var alreadyRunning = IsScopeHealthy(scope);
|
||||||
EnsureWindow(scope, timeout, alreadyRunning);
|
var recoveredByUs = EnsureWindow(scope, timeout, alreadyRunning);
|
||||||
return !alreadyRunning;
|
return !alreadyRunning || recoveredByUs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -158,20 +174,26 @@ public sealed class SessionHelper
|
|||||||
/// the runner is single-instance, so <c>--open-settings</c> just (re)shows Settings — and
|
/// the runner is single-instance, so <c>--open-settings</c> just (re)shows Settings — and
|
||||||
/// additionally clears the single-instance mutex first only for a fresh launch that has gone
|
/// 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
|
/// 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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
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 processName = GetProcessName(scope);
|
||||||
var runnerName = GetProcessName(PowerToysModule.Runner);
|
var runnerName = GetProcessName(PowerToysModule.Runner);
|
||||||
var nudgeInterval = TimeSpan.FromSeconds(25);
|
var nudgeInterval = TimeSpan.FromSeconds(25);
|
||||||
|
var recoveredByUs = false;
|
||||||
|
|
||||||
if (!alreadyRunning)
|
if (!alreadyRunning)
|
||||||
{
|
{
|
||||||
// Release the single-instance mutex any stale/half-launched instance still holds (pre-test
|
// Release the single-instance mutex any stale/half-launched instance still holds (pre-test
|
||||||
// hygiene kills without waiting), then launch.
|
// hygiene kills without waiting), then launch.
|
||||||
KillScopeProcessesAndWait(scope);
|
if (TryKillScopeProcessesAndWait(scope, TimeSpan.FromSeconds(10), out _))
|
||||||
|
{
|
||||||
LaunchScope(scope);
|
LaunchScope(scope);
|
||||||
|
recoveredByUs = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var deadline = DateTime.UtcNow + timeout;
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
@@ -179,11 +201,11 @@ public sealed class SessionHelper
|
|||||||
|
|
||||||
while (DateTime.UtcNow < deadline)
|
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.
|
// Give XAML a moment to populate the visual tree.
|
||||||
Thread.Sleep(750);
|
Thread.Sleep(750);
|
||||||
return;
|
return recoveredByUs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DateTime.UtcNow - lastLaunch > nudgeInterval)
|
if (DateTime.UtcNow - lastLaunch > nudgeInterval)
|
||||||
@@ -196,18 +218,28 @@ public sealed class SessionHelper
|
|||||||
// honours with a SEPARATE Settings.exe (the "Settings: 3" pile-up seen in CI), and the
|
// 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
|
// 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.
|
// the deadline. So when anything is alive, keep waiting instead of piling on.
|
||||||
var alive = IsRunning(scope) || Process.GetProcessesByName(runnerName).Length > 0;
|
var scopeAlive = IsRunning(scope);
|
||||||
if (!alive)
|
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);
|
LaunchScope(scope);
|
||||||
|
recoveredByUs = true;
|
||||||
lastLaunch = DateTime.UtcNow;
|
lastLaunch = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (!scopeAlive && !runnerAlive)
|
||||||
|
{
|
||||||
|
if (alreadyRunning || TryKillScopeProcessesAndWait(scope, TimeSpan.FromSeconds(10), out _))
|
||||||
|
{
|
||||||
|
LaunchScope(scope);
|
||||||
|
recoveredByUs = true;
|
||||||
|
lastLaunch = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Thread.Sleep(500);
|
Thread.Sleep(500);
|
||||||
}
|
}
|
||||||
@@ -216,6 +248,7 @@ public sealed class SessionHelper
|
|||||||
$"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds:0}s. " +
|
$"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds:0}s. " +
|
||||||
$"Live processes — runner '{runnerName}': {Process.GetProcessesByName(runnerName).Length}, " +
|
$"Live processes — runner '{runnerName}': {Process.GetProcessesByName(runnerName).Length}, " +
|
||||||
$"'{processName}': {Process.GetProcessesByName(processName).Length}.");
|
$"'{processName}': {Process.GetProcessesByName(processName).Length}.");
|
||||||
|
return recoveredByUs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -242,6 +275,18 @@ public sealed class SessionHelper
|
|||||||
/// new launch off to the dying instance, which never presents a window.
|
/// new launch off to the dying instance, which never presents a window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void KillScopeProcessesAndWait(PowerToysModule scope)
|
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<string> remaining)
|
||||||
{
|
{
|
||||||
var names = scope == PowerToysModule.PowerToysSettings
|
var names = scope == PowerToysModule.PowerToysSettings
|
||||||
? new[] { GetProcessName(PowerToysModule.PowerToysSettings), GetProcessName(PowerToysModule.Runner) }
|
? new[] { GetProcessName(PowerToysModule.PowerToysSettings), GetProcessName(PowerToysModule.Runner) }
|
||||||
@@ -252,11 +297,40 @@ public sealed class SessionHelper
|
|||||||
WindowControl.TryKillProcessByName(name);
|
WindowControl.TryKillProcessByName(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
while (DateTime.UtcNow < deadline && names.Any(n => Process.GetProcessesByName(n).Length > 0))
|
while (DateTime.UtcNow < deadline)
|
||||||
{
|
{
|
||||||
|
remaining = GetRunningProcessNames(names);
|
||||||
|
if (remaining.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Thread.Sleep(150);
|
Thread.Sleep(150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remaining = GetRunningProcessNames(names);
|
||||||
|
return remaining.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> GetRunningProcessNames(IEnumerable<string> names)
|
||||||
|
{
|
||||||
|
var running = new List<string>();
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public class UITestBase : IDisposable
|
|||||||
// inherited ClassCleanup stops it once the owning class finishes.
|
// inherited ClassCleanup stops it once the owning class finishes.
|
||||||
private static SessionHelper? keepAliveHelper;
|
private static SessionHelper? keepAliveHelper;
|
||||||
private static Type? keepAliveOwner;
|
private static Type? keepAliveOwner;
|
||||||
|
private static IDisposable? keepAliveSettingsSnapshot;
|
||||||
|
|
||||||
private readonly PowerToysModule scope;
|
private readonly PowerToysModule scope;
|
||||||
private readonly WindowSize windowSize;
|
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
|
// 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.
|
// into a shared scope and it's still alive — skip the hygiene that would minimize/kill it.
|
||||||
var reuse = ReuseScopeAcrossTests
|
var ownsSharedScope = ReuseScopeAcrossTests && keepAliveOwner == GetType();
|
||||||
&& keepAliveOwner == GetType()
|
var reuse = ownsSharedScope && SessionHelper.IsScopeHealthy(scope);
|
||||||
&& SessionHelper.IsRunning(scope);
|
|
||||||
|
|
||||||
if (!reuse)
|
if (!reuse)
|
||||||
{
|
{
|
||||||
@@ -124,7 +124,11 @@ public class UITestBase : IDisposable
|
|||||||
DisplayHelper.LogMonitors(TestContext);
|
DisplayHelper.LogMonitors(TestContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ownsSharedScope)
|
||||||
|
{
|
||||||
firstRunSettingsSnapshot = SettingsConfigHelper.PreserveFirstRunSettings();
|
firstRunSettingsSnapshot = SettingsConfigHelper.PreserveFirstRunSettings();
|
||||||
|
}
|
||||||
|
|
||||||
PreTestHygiene();
|
PreTestHygiene();
|
||||||
|
|
||||||
// Seed a deterministic module on/off baseline before the runner reads settings.json.
|
// Seed a deterministic module on/off baseline before the runner reads settings.json.
|
||||||
@@ -154,6 +158,11 @@ public class UITestBase : IDisposable
|
|||||||
{
|
{
|
||||||
keepAliveHelper = sessionHelper;
|
keepAliveHelper = sessionHelper;
|
||||||
keepAliveOwner = GetType();
|
keepAliveOwner = GetType();
|
||||||
|
if (!ownsSharedScope)
|
||||||
|
{
|
||||||
|
keepAliveSettingsSnapshot = firstRunSettingsSnapshot;
|
||||||
|
firstRunSettingsSnapshot = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -205,15 +214,9 @@ public class UITestBase : IDisposable
|
|||||||
// Tear the scope down only when each test owns its launch. With a class-shared scope the
|
// Tear the scope down only when each test owns its launch. With a class-shared scope the
|
||||||
// window must survive for the next test; the inherited ClassCleanup stops it at class end.
|
// window must survive for the next test; the inherited ClassCleanup stops it at class end.
|
||||||
if (!ReuseScopeAcrossTests)
|
if (!ReuseScopeAcrossTests)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
sessionHelper?.StopIfStarted();
|
sessionHelper?.StopIfStarted();
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -226,21 +229,41 @@ public class UITestBase : IDisposable
|
|||||||
/// tests finish. Runs after every derived class via inheritance; a no-op for classes that never
|
/// tests finish. Runs after every derived class via inheritance; a no-op for classes that never
|
||||||
/// kept a scope alive.
|
/// kept a scope alive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass)]
|
[ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass, ClassCleanupBehavior.EndOfClass)]
|
||||||
public static void StopSharedScope()
|
public static void StopSharedScope()
|
||||||
{
|
{
|
||||||
|
var failures = new List<Exception>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
keepAliveHelper?.StopIfStarted();
|
keepAliveHelper?.StopIfStarted();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
failures.Add(ex);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
keepAliveHelper = null;
|
keepAliveHelper = null;
|
||||||
keepAliveOwner = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collect every diagnostic for a failed test and attach it: a window-independent desktop
|
/// Collect every diagnostic for a failed test and attach it: a window-independent desktop
|
||||||
/// screenshot always, plus (in pipeline mode) the 1s screenshot trail, the screen recording, and
|
/// screenshot always, plus (in pipeline mode) the 1s screenshot trail, the screen recording, and
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the item captions of the real <c>HMENU</c> behind a classic (<c>#32768</c>) context-menu
|
||||||
|
/// window, by asking the window for its menu handle (<c>MN_GETHMENU</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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, <c>GetMenuItemInfo(MIIM_BITMAP)</c> returns a null
|
||||||
|
/// <c>hbmpItem</c> for every Explorer menu item, so icon presence is measured from pixels instead.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class ClassicContextMenu
|
||||||
|
{
|
||||||
|
/// <summary>Window class of a classic Win32 popup menu.</summary>
|
||||||
|
public const string WindowClassName = "#32768";
|
||||||
|
|
||||||
|
private const uint MNGETHMENU = 0x01E1;
|
||||||
|
private const uint MFBYPOSITION = 0x00000400;
|
||||||
|
private const uint SMTOABORTIFHUNG = 0x0002;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captions of every item of the popup menu owned by <paramref name="menuWindow"/>, or null when
|
||||||
|
/// the window no longer owns a menu (a transient popup can vanish mid-read).
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string>? 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<string>(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||||
|
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>Microsoft.PowerToys.PowerRename.UITests</RootNamespace>
|
||||||
|
<AssemblyName>PowerRename.UITests.Next</AssemblyName>
|
||||||
|
<!-- Element.Click uses physical winappcli bounds with Win32 cursor input. -->
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
|
||||||
|
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
|
||||||
|
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
|
||||||
|
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
|
||||||
|
<RunVSTest>false</RunVSTest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
|
||||||
|
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerRename.UITests.Next\</OutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MSTest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Covers checklist items 1-3 of microsoft/PowerToys#40663.</remarks>
|
||||||
|
[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<ToggleSwitch>(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<NavigationViewItem>(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<NavigationViewItem>(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<ToggleSwitch>(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<bool>();
|
||||||
|
}
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>The Windows 11 tier-1 (sparse-MSIX, <c>IExplorerCommand</c>) menu.</summary>
|
||||||
|
Modern,
|
||||||
|
|
||||||
|
/// <summary>The classic <c>#32768</c> menu, reached through "Show more options" on Windows 11.</summary>
|
||||||
|
Classic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether this OS/build actually shows the tier-1 surface.</summary>
|
||||||
|
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<string>? captions) =>
|
||||||
|
captions?.Any(caption => caption.Equals(ContextMenuCaption, StringComparison.OrdinalIgnoreCase)) == true;
|
||||||
|
|
||||||
|
private static string Describe(IReadOnlyList<string>? captions) =>
|
||||||
|
captions is null
|
||||||
|
? "Menu items: <not read>."
|
||||||
|
: $"Menu items: [{string.Join(", ", captions.Select(caption => $"'{caption}'"))}].";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crop of the PowerRename entry's row, taken from the live desktop so the popup menu (which no
|
||||||
|
/// window-scoped capture reaches) is included.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<int, int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requireEntry">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string> 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<Element>(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!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The preview list itself: per-item inclusion, the renamed-only filter, and the header
|
||||||
|
/// select/deselect-all control.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pick an entry of the Filter flyout, which the window hosts in its own popup.</summary>
|
||||||
|
private void SelectFilter(Session window, string itemAutomationId)
|
||||||
|
{
|
||||||
|
Step($"Selecting filter '{itemAutomationId}'");
|
||||||
|
window.Find<Button>(By.AccessibilityId(FilterButtonAutomationId), PreviewTimeoutMS)
|
||||||
|
.Invoke(msPostAction: 500);
|
||||||
|
window.Find<Element>(By.AccessibilityId(itemAutomationId), PreviewTimeoutMS)
|
||||||
|
.Invoke(msPostAction: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rename options exposed by the PowerRename window: text formatting, item-type exclusions, the
|
||||||
|
/// "Apply to" scope, enumeration, case sensitivity, and match-all-occurrences.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Covers checklist items 6-11 of microsoft/PowerToys#40663 and replaces the legacy
|
||||||
|
/// <c>BasicRenameTests.BasicInput</c>, <c>BasicMatchFileName</c>, <c>BasicMatchAllOccurrences</c>,
|
||||||
|
/// and <c>BasicCaseSensitive</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class PowerRenameTests
|
||||||
|
{
|
||||||
|
private const string Uppercase = "toggleButton_upperCase";
|
||||||
|
private const string Lowercase = "toggleButton_lowerCase";
|
||||||
|
private const string TitleCase = "toggleButton_titleCase";
|
||||||
|
private const string CapitalizeEachWord = "toggleButton_capitalize";
|
||||||
|
private const string IncludeFiles = "toggleButton_includeFiles";
|
||||||
|
private const string IncludeFolders = "toggleButton_includeFolders";
|
||||||
|
private const string IncludeSubfolders = "toggleButton_includeSubfolders";
|
||||||
|
|
||||||
|
private static readonly string[] TextFormattingModes = { Uppercase, Lowercase, TitleCase, CapitalizeEachWord };
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Preview.SearchAndReplace")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void SearchAndReplaceUpdatesThePreview()
|
||||||
|
{
|
||||||
|
// Legacy BasicInput + BasicMatchFileName.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "testCase1.txt");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
|
||||||
|
SetSearchText(window, "testCase1");
|
||||||
|
SetReplaceText(window, "replaced");
|
||||||
|
|
||||||
|
Assert.AreEqual("testCase1", GetSearchText(window), "The search box did not keep the typed text.");
|
||||||
|
Assert.AreEqual("replaced", GetReplaceText(window), "The replace box did not keep the typed text.");
|
||||||
|
WaitForPreviewName(window, "replaced.txt");
|
||||||
|
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "replaced.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.TextFormatting.MutuallyExclusive")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void TextFormattingModesAreMutuallyExclusive()
|
||||||
|
{
|
||||||
|
// Checklist item 6 — only one casing mode can be active at a time.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "hello world.TXT");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
|
||||||
|
foreach (var mode in TextFormattingModes)
|
||||||
|
{
|
||||||
|
SetToggleButton(window, mode, true);
|
||||||
|
foreach (var other in TextFormattingModes.Where(candidate => candidate != mode))
|
||||||
|
{
|
||||||
|
Assert.IsFalse(
|
||||||
|
IsToggleButtonOn(window, other),
|
||||||
|
$"Selecting '{mode}' left '{other}' active; the casing modes must be mutually exclusive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.TextFormatting.Rename")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
[DataRow(Uppercase, "HELLO WORLD.TXT")]
|
||||||
|
[DataRow(Lowercase, "hello world.txt")]
|
||||||
|
[DataRow(TitleCase, "Hello World.TXT")]
|
||||||
|
public void TextFormattingRenamesUsingTheSelectedMode(string modeAutomationId, string expectedName)
|
||||||
|
{
|
||||||
|
// Checklist item 6 — each casing mode applied on its own with no search term.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "hello world.TXT");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
|
||||||
|
SetToggleButton(window, modeAutomationId, true);
|
||||||
|
WaitForPreviewName(window, expectedName);
|
||||||
|
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, expectedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Exclude.FilesFoldersSubfolders")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void ExcludingFilesFoldersAndSubfolderItemsFiltersTheRename()
|
||||||
|
{
|
||||||
|
// Checklist item 7 — the three exclusions are independent and combinable.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var file = CreateFile(folder, "filex.txt");
|
||||||
|
var subFolder = CreateSubFolder(folder, "folderx");
|
||||||
|
CreateFile(subFolder, "innerx.txt");
|
||||||
|
|
||||||
|
var window = LaunchPowerRename(file, subFolder);
|
||||||
|
SetSearchText(window, "x");
|
||||||
|
SetReplaceText(window, "y");
|
||||||
|
|
||||||
|
WaitForPreviewName(window, "filey.txt");
|
||||||
|
WaitForPreviewName(window, "foldery");
|
||||||
|
WaitForPreviewName(window, "innery.txt");
|
||||||
|
|
||||||
|
SetToggleButton(window, IncludeFiles, false);
|
||||||
|
WaitForPreviewToDropName(window, "filey.txt");
|
||||||
|
WaitForPreviewToDropName(window, "innery.txt");
|
||||||
|
WaitForPreviewName(window, "foldery");
|
||||||
|
|
||||||
|
SetToggleButton(window, IncludeFiles, true);
|
||||||
|
SetToggleButton(window, IncludeFolders, false);
|
||||||
|
WaitForPreviewToDropName(window, "foldery");
|
||||||
|
WaitForPreviewName(window, "filey.txt");
|
||||||
|
WaitForPreviewName(window, "innery.txt");
|
||||||
|
|
||||||
|
SetToggleButton(window, IncludeFolders, true);
|
||||||
|
SetToggleButton(window, IncludeSubfolders, false);
|
||||||
|
WaitForPreviewToDropName(window, "innery.txt");
|
||||||
|
WaitForPreviewName(window, "filey.txt");
|
||||||
|
WaitForPreviewName(window, "foldery");
|
||||||
|
|
||||||
|
// Several exclusions at once: with files and folders both excluded nothing is left to rename.
|
||||||
|
SetToggleButton(window, IncludeFiles, false);
|
||||||
|
SetToggleButton(window, IncludeFolders, false);
|
||||||
|
WaitForRenamedCount(window, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.ApplyTo.Scope")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
[DataRow("RenamePartsFilenameAndExtension", "info.info")]
|
||||||
|
[DataRow("RenamePartsFilenameOnly", "info.data")]
|
||||||
|
[DataRow("RenamePartsExtensionOnly", "data.info")]
|
||||||
|
public void ApplyToScopeLimitsTheReplacement(string itemAutomationId, string expectedName)
|
||||||
|
{
|
||||||
|
// Checklist item 8 — the "Apply to" combo box selects exactly one scope.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "data.data");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
|
||||||
|
SetOptionCheckBox(window, MatchAllOccurrencesAutomationId, true);
|
||||||
|
SetSearchText(window, "data");
|
||||||
|
SetReplaceText(window, "info");
|
||||||
|
SelectApplyTo(window, itemAutomationId);
|
||||||
|
|
||||||
|
WaitForPreviewName(window, expectedName);
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, expectedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Enumerate.AdvancedCounter")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void EnumerateItemsHonoursAdvancedCounterSyntax()
|
||||||
|
{
|
||||||
|
// Checklist item 9 — ${start=10,increment=2,padding=4} over three items.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var first = CreateFile(folder, "a.txt");
|
||||||
|
var second = CreateFile(folder, "b.txt");
|
||||||
|
var third = CreateFile(folder, "c.txt");
|
||||||
|
|
||||||
|
var window = LaunchPowerRename(first, second, third);
|
||||||
|
SetToggleButton(window, "toggleButton_enumItems", true);
|
||||||
|
SetOptionCheckBox(window, RegularExpressionsAutomationId, true);
|
||||||
|
SetSearchText(window, ".*");
|
||||||
|
SetReplaceText(window, "item_${start=10,increment=2,padding=4}.txt");
|
||||||
|
|
||||||
|
WaitForPreviewName(window, "item_0010.txt");
|
||||||
|
WaitForPreviewName(window, "item_0012.txt");
|
||||||
|
WaitForPreviewName(window, "item_0014.txt");
|
||||||
|
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "item_0010.txt", "item_0012.txt", "item_0014.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Search.CaseSensitive")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void CaseSensitiveSearchOnlyMatchesTheExactCasing()
|
||||||
|
{
|
||||||
|
// Checklist item 10, replacing the legacy BasicCaseSensitive.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "testCase1.txt");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
|
||||||
|
SetSearchText(window, "testcase1");
|
||||||
|
SetReplaceText(window, "match1");
|
||||||
|
WaitForPreviewName(window, "match1.txt");
|
||||||
|
WaitForRenamedCount(window, 1);
|
||||||
|
|
||||||
|
SetOptionCheckBox(window, CaseSensitiveAutomationId, true);
|
||||||
|
WaitForPreviewToDropName(window, "match1.txt");
|
||||||
|
WaitForRenamedCount(window, 0);
|
||||||
|
|
||||||
|
SetOptionCheckBox(window, CaseSensitiveAutomationId, false);
|
||||||
|
WaitForPreviewName(window, "match1.txt");
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "match1.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Search.MatchAllOccurrences")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void MatchAllOccurrencesReplacesEveryMatch()
|
||||||
|
{
|
||||||
|
// Checklist item 11, replacing the legacy BasicMatchAllOccurrences.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "test-test.txt");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
|
||||||
|
SetSearchText(window, "test");
|
||||||
|
SetReplaceText(window, "best");
|
||||||
|
WaitForPreviewName(window, "best-test.txt");
|
||||||
|
|
||||||
|
SetOptionCheckBox(window, MatchAllOccurrencesAutomationId, true);
|
||||||
|
WaitForPreviewName(window, "best-best.txt");
|
||||||
|
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "best-best.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// 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.Globalization;
|
||||||
|
using Microsoft.PowerToys.UITest.Next;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
|
||||||
|
namespace Microsoft.PowerToys.PowerRename.UITests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regular-expression search and replace, date/time replacement terms, and the Boost engine.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Covers checklist items 12-15 of microsoft/PowerToys#40663 and replaces the legacy
|
||||||
|
/// <c>BasicRenameTests.BasicRegularMatch</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class PowerRenameTests
|
||||||
|
{
|
||||||
|
[TestMethod("PowerRename.RegEx.ToggleChangesMatching")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void RegularExpressionToggleChangesWhatMatches()
|
||||||
|
{
|
||||||
|
// Legacy BasicRegularMatch: the pattern only matches once regex mode is on.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var first = CreateFile(folder, "testCase1.txt");
|
||||||
|
var second = CreateFile(folder, "testCase2.txt");
|
||||||
|
var other = CreateFile(folder, "other.txt");
|
||||||
|
var window = LaunchPowerRename(first, second, other);
|
||||||
|
|
||||||
|
SetSearchText(window, "^test.*\\.txt$");
|
||||||
|
SetReplaceText(window, "matched.txt");
|
||||||
|
WaitForRenamedCount(window, 0);
|
||||||
|
|
||||||
|
SetOptionCheckBox(window, RegularExpressionsAutomationId, true);
|
||||||
|
WaitForRenamedCount(window, 2);
|
||||||
|
WaitForPreviewName(window, "matched.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.RegEx.CaptureGroup")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void RegularExpressionSearchAndReplaceUsesCaptureGroups()
|
||||||
|
{
|
||||||
|
// Checklist items 12 and 13 — search "(.*).png" and replace "foo_$1.png".
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var image = CreateFile(folder, "photo.png");
|
||||||
|
var text = CreateFile(folder, "notes.txt");
|
||||||
|
var window = LaunchPowerRename(image, text);
|
||||||
|
|
||||||
|
SetOptionCheckBox(window, RegularExpressionsAutomationId, true);
|
||||||
|
SetSearchText(window, "(.*).png");
|
||||||
|
SetReplaceText(window, "foo_$1.png");
|
||||||
|
|
||||||
|
WaitForPreviewName(window, "foo_photo.png");
|
||||||
|
WaitForRenamedCount(window, 1);
|
||||||
|
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "foo_photo.png", "notes.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.RegEx.FileCreationDateTime")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void ReplaceTermCanUseFileCreationDateAndTime()
|
||||||
|
{
|
||||||
|
// Checklist item 14 — exercise both example forms, including milliseconds and month name.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "stamp.txt");
|
||||||
|
var fileTime = new DateTime(2020, 2, 3, 4, 5, 6, 789, DateTimeKind.Local);
|
||||||
|
File.SetCreationTime(source, fileTime);
|
||||||
|
File.SetLastWriteTime(source, fileTime);
|
||||||
|
File.SetLastAccessTime(source, fileTime);
|
||||||
|
fileTime = File.GetCreationTime(source);
|
||||||
|
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
SetSearchText(window, "stamp");
|
||||||
|
SetReplaceText(window, "$hh-$mm-$ss-$fff_$DD_$MMMM_$YYYY");
|
||||||
|
|
||||||
|
var month = fileTime.ToString("MMMM", CultureInfo.CurrentCulture);
|
||||||
|
month = char.ToUpper(month[0], CultureInfo.CurrentCulture) + month[1..];
|
||||||
|
var expectedName = $"{fileTime:HH-mm-ss-fff_dd}_{month}_{fileTime:yyyy}.txt";
|
||||||
|
WaitForPreviewName(window, expectedName);
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, expectedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.RegEx.BoostPerlSyntax")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void BoostLibraryEnablesPerlRegularExpressionSyntax()
|
||||||
|
{
|
||||||
|
// Checklist item 15 — a lookbehind only resolves with the Boost engine. UseBoostLib is read
|
||||||
|
// when the rename engine is constructed, so each half needs its own PowerRename process.
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var matching = CreateFile(folder, "test.txt");
|
||||||
|
var notMatching = CreateFile(folder, "est.txt");
|
||||||
|
|
||||||
|
ConfigureModuleSettings(useBoostLib: false, persistState: false, mruEnabled: false);
|
||||||
|
var window = LaunchPowerRename(matching, notMatching);
|
||||||
|
SetOptionCheckBox(window, RegularExpressionsAutomationId, true);
|
||||||
|
SetSearchText(window, "(?<=t)est");
|
||||||
|
SetReplaceText(window, "XYZ");
|
||||||
|
WaitForRenamedCount(window, 0);
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
|
||||||
|
ConfigureModuleSettings(useBoostLib: true, persistState: false, mruEnabled: false);
|
||||||
|
window = LaunchPowerRename(matching, notMatching);
|
||||||
|
SetOptionCheckBox(window, RegularExpressionsAutomationId, true);
|
||||||
|
SetSearchText(window, "(?<=t)est");
|
||||||
|
SetReplaceText(window, "XYZ");
|
||||||
|
WaitForPreviewName(window, "tXYZ.txt");
|
||||||
|
WaitForRenamedCount(window, 1);
|
||||||
|
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "tXYZ.txt", "est.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PowerRename settings that change what the window offers on launch: the autocomplete (MRU) list
|
||||||
|
/// and restoring the values from the last use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Covers checklist items 4 and 5 of microsoft/PowerToys#40663.</remarks>
|
||||||
|
public sealed partial class PowerRenameTests
|
||||||
|
{
|
||||||
|
private const string SeedSearchTerm = "seedterm";
|
||||||
|
private const string SeedReplaceTerm = "replaceterm";
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Settings.AutoComplete")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void AutoCompleteOffersPreviousTermsOnlyWhileEnabled()
|
||||||
|
{
|
||||||
|
// Checklist item 4 — the search box suggests earlier terms exactly while autocomplete is on.
|
||||||
|
ConfigureModuleSettings(mruEnabled: true, persistState: true);
|
||||||
|
SeedMostRecentlyUsedTerms();
|
||||||
|
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var plain = CreateFile(folder, "plain.txt");
|
||||||
|
|
||||||
|
var window = LaunchPowerRename(plain);
|
||||||
|
Assert.IsTrue(
|
||||||
|
SuggestionAppears(window, "seed", SeedSearchTerm),
|
||||||
|
$"Autocomplete was enabled but the search box never suggested '{SeedSearchTerm}'.");
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
|
||||||
|
ConfigureModuleSettings(mruEnabled: false, persistState: true);
|
||||||
|
window = LaunchPowerRename(plain);
|
||||||
|
Assert.IsFalse(
|
||||||
|
SuggestionAppears(window, "seed", SeedSearchTerm),
|
||||||
|
$"Autocomplete was disabled but the search box still suggested '{SeedSearchTerm}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod("PowerRename.Settings.RestoreLastUsedValues")]
|
||||||
|
[TestCategory("PowerRename")]
|
||||||
|
public void LastUsedValuesAreRestoredOnlyWhileEnabled()
|
||||||
|
{
|
||||||
|
// Checklist item 5 — "Show values from last use" restores the search/replace text and flags.
|
||||||
|
ConfigureModuleSettings(persistState: true, mruEnabled: true);
|
||||||
|
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, "keep.txt");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
SetSearchText(window, "keep");
|
||||||
|
SetReplaceText(window, "kept");
|
||||||
|
SetOptionCheckBox(window, CaseSensitiveAutomationId, true);
|
||||||
|
WaitForPreviewName(window, "kept.txt");
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, "kept.txt");
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
|
||||||
|
var second = CreateFile(folder, "second.txt");
|
||||||
|
window = LaunchPowerRename(second);
|
||||||
|
Assert.AreEqual("keep", GetSearchText(window), "The search text from the last use was not restored.");
|
||||||
|
Assert.AreEqual("kept", GetReplaceText(window), "The replace text from the last use was not restored.");
|
||||||
|
Assert.IsTrue(
|
||||||
|
window.Find<CheckBox>(By.AccessibilityId(CaseSensitiveAutomationId), PreviewTimeoutMS).IsChecked,
|
||||||
|
"The 'Case sensitive' flag from the last use was not restored.");
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
|
||||||
|
ConfigureModuleSettings(persistState: false, mruEnabled: true);
|
||||||
|
window = LaunchPowerRename(second);
|
||||||
|
|
||||||
|
// An empty AutoSuggestBox reports its placeholder through UIA, so assert the previous values
|
||||||
|
// are gone rather than that the boxes read as empty.
|
||||||
|
Assert.AreNotEqual("keep", GetSearchText(window), "The search text was restored even though the setting is off.");
|
||||||
|
Assert.AreNotEqual("kept", GetReplaceText(window), "The replace text was restored even though the setting is off.");
|
||||||
|
Assert.IsFalse(
|
||||||
|
window.Find<CheckBox>(By.AccessibilityId(CaseSensitiveAutomationId), PreviewTimeoutMS).IsChecked,
|
||||||
|
"A flag was restored even though the setting is off.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Perform one real rename so PowerRename records the terms in its MRU lists.</summary>
|
||||||
|
private void SeedMostRecentlyUsedTerms()
|
||||||
|
{
|
||||||
|
var folder = CreateTestFolder();
|
||||||
|
var source = CreateFile(folder, SeedSearchTerm + ".txt");
|
||||||
|
var window = LaunchPowerRename(source);
|
||||||
|
SetSearchText(window, SeedSearchTerm);
|
||||||
|
SetReplaceText(window, SeedReplaceTerm);
|
||||||
|
ApplyRenameAndAssertEntries(window, folder, SeedReplaceTerm + ".txt");
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type into the search box with real keystrokes — the suggestion list only opens for user input,
|
||||||
|
/// not for a programmatic value change — and report whether the expected suggestion is offered.
|
||||||
|
/// </summary>
|
||||||
|
private bool SuggestionAppears(Session window, string typedText, string expectedSuggestion)
|
||||||
|
{
|
||||||
|
Assert.IsTrue(TypeIntoSearchBox(window, typedText), $"The typed text '{typedText}' never reached the search box.");
|
||||||
|
if (HasSuggestion(window, expectedSuggestion))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A list that did not open on typing still opens on Down; only then is "no suggestion" real.
|
||||||
|
KeyboardHelper.SendKey(Key.Down);
|
||||||
|
Thread.Sleep(500);
|
||||||
|
return HasSuggestion(window, expectedSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send real keystrokes and confirm from the box's own value that they landed — injected input
|
||||||
|
/// goes to whatever owns the foreground, which is not something a test host can simply assert.
|
||||||
|
/// </summary>
|
||||||
|
private bool TypeIntoSearchBox(Session window, string typedText)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; attempt <= 3; attempt++)
|
||||||
|
{
|
||||||
|
Step($"Typing '{typedText}' into the search box to open the suggestion list (attempt {attempt})");
|
||||||
|
TryBringPowerRenameForward();
|
||||||
|
|
||||||
|
var box = window.Find<TextBox>(By.AccessibilityId(SearchBoxAutomationId), timeoutMS: PreviewTimeoutMS);
|
||||||
|
box.SetText(string.Empty);
|
||||||
|
box.Focus();
|
||||||
|
Thread.Sleep(300);
|
||||||
|
KeyboardHelper.SendKeySequence(typedText.Select(ToKey).ToArray());
|
||||||
|
Thread.Sleep(500);
|
||||||
|
|
||||||
|
if (window.Find<TextBox>(By.AccessibilityId(SearchBoxAutomationId), timeoutMS: 2_000).Value == typedText)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasSuggestion(Session window, string suggestion) =>
|
||||||
|
FindExact<Element>(window, suggestion, timeoutMS: 2_000) is not null;
|
||||||
|
|
||||||
|
private static Key ToKey(char character)
|
||||||
|
{
|
||||||
|
var upper = char.ToUpperInvariant(character);
|
||||||
|
Assert.IsTrue(upper is >= 'A' and <= 'Z', $"Only letters can be typed by this helper, got '{character}'.");
|
||||||
|
return (Key)upper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
// 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.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.PowerToys.UITest.Next;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
|
||||||
|
namespace Microsoft.PowerToys.PowerRename.UITests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared fixture for the PowerRename suites: a deterministic module baseline, temporary rename
|
||||||
|
/// fixtures, and the launch/drive/assert helpers every test needs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The scope is the Settings/runner scope because the runner owns module enablement and therefore
|
||||||
|
/// the context-menu registration. The PowerRename window itself is launched per test with its item
|
||||||
|
/// list on the command line, which is how the shell extension starts it for a user selection.
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class PowerRenameTestBase : UITestBase
|
||||||
|
{
|
||||||
|
protected const string PowerRenameProcessName = "PowerToys.PowerRename";
|
||||||
|
protected const string ContextMenuCaption = "Rename with PowerRename";
|
||||||
|
protected const string SearchBoxAutomationId = "textBox_search";
|
||||||
|
protected const string ReplaceBoxAutomationId = "textBox_replace";
|
||||||
|
protected const string ApplyButtonAutomationId = "button_rename";
|
||||||
|
protected const string RegularExpressionsAutomationId = "checkBox_regex";
|
||||||
|
protected const string MatchAllOccurrencesAutomationId = "checkBox_matchAll";
|
||||||
|
protected const string CaseSensitiveAutomationId = "checkBox_case";
|
||||||
|
protected const string OriginalCountAutomationId = "OriginalCount";
|
||||||
|
protected const string RenamedCountAutomationId = "RenamedCount";
|
||||||
|
protected const int WindowTimeoutMS = 30_000;
|
||||||
|
protected const int PreviewTimeoutMS = 30_000;
|
||||||
|
protected const int RenameTimeoutMS = 30_000;
|
||||||
|
|
||||||
|
private static readonly string[] PowerRenameModuleOnly = { "PowerRename" };
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true };
|
||||||
|
|
||||||
|
// Everything PowerRename persists under its settings folder. A test that changes any of it must
|
||||||
|
// hand the profile back exactly as it found it.
|
||||||
|
private static readonly string[] ModuleStateFileNames =
|
||||||
|
{
|
||||||
|
"power-rename-settings.json",
|
||||||
|
"power-rename-last-run-data.json",
|
||||||
|
"power-rename-ui-flags",
|
||||||
|
"search-mru.json",
|
||||||
|
"replace-mru.json",
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly List<string> temporaryFolders = new();
|
||||||
|
private readonly Dictionary<string, byte[]?> moduleStateSnapshot = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, string> lastAppliedText = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
protected PowerRenameTestBase()
|
||||||
|
: base(PowerToysModule.PowerToysSettings, WindowSize.UnSpecified, PowerRenameModuleOnly)
|
||||||
|
{
|
||||||
|
// Snapshot before the base class launches the runner, so the profile's original PowerRename
|
||||||
|
// state survives even the very first test in the class.
|
||||||
|
foreach (var fileName in ModuleStateFileNames)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(ModuleSettingsDirectory, fileName);
|
||||||
|
moduleStateSnapshot[path] = File.Exists(path) ? File.ReadAllBytes(path) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override bool ReuseScopeAcrossTests => true;
|
||||||
|
|
||||||
|
protected override IReadOnlyList<string> StaleProcessNames { get; } = new[]
|
||||||
|
{
|
||||||
|
"PowerToys",
|
||||||
|
"PowerToys.Settings",
|
||||||
|
PowerRenameProcessName,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary><c>%LocalAppData%\Microsoft\PowerToys\PowerRename</c>.</summary>
|
||||||
|
protected static string ModuleSettingsDirectory { get; } =
|
||||||
|
Path.Combine(SettingsConfigHelper.PowerToysSettingsRoot, "PowerRename");
|
||||||
|
|
||||||
|
protected static bool IsWindows11OrNewer => Environment.OSVersion.Version.Build >= 22_000;
|
||||||
|
|
||||||
|
[TestInitialize]
|
||||||
|
public void PreparePowerRenameBaseline()
|
||||||
|
{
|
||||||
|
// The harness seeds the enabled-module baseline once per class. PowerRename's shell
|
||||||
|
// extension re-reads settings.json on every context-menu query, so re-assert it per test.
|
||||||
|
EnsureModuleEnabledInGlobalSettings();
|
||||||
|
ConfigureModuleSettings(
|
||||||
|
showIcon: true,
|
||||||
|
extendedContextMenuOnly: false,
|
||||||
|
persistState: false,
|
||||||
|
mruEnabled: false,
|
||||||
|
maxMruSize: 10,
|
||||||
|
useBoostLib: false);
|
||||||
|
ClearPersistedRenameState();
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestCleanup]
|
||||||
|
public async Task CleanupPowerRenameTest()
|
||||||
|
{
|
||||||
|
await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2));
|
||||||
|
var failures = new List<Exception>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
failures.AddRange(RestoreModuleState());
|
||||||
|
|
||||||
|
foreach (var folder in temporaryFolders)
|
||||||
|
{
|
||||||
|
if (!TryDeleteDirectory(folder))
|
||||||
|
{
|
||||||
|
failures.Add(new IOException($"Cleanup could not delete temporary folder '{folder}'."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
temporaryFolders.Clear();
|
||||||
|
if (failures.Count > 0)
|
||||||
|
{
|
||||||
|
throw new AggregateException("PowerRename test cleanup failed.", failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Timestamped trace so a CI hang names the step it stuck on.</summary>
|
||||||
|
protected void Step(string message) =>
|
||||||
|
TestContext.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}");
|
||||||
|
|
||||||
|
// ---- fixtures -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
protected string CreateTestFolder()
|
||||||
|
{
|
||||||
|
var folder = Path.Combine(Path.GetTempPath(), "PowerToys-PowerRename-UITests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
temporaryFolders.Add(folder);
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static string CreateFile(string folder, string fileName)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(folder, fileName);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, "PowerRename UI test fixture.");
|
||||||
|
Assert.IsTrue(File.Exists(path), $"Fixture file was not written to disk at '{path}'.");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static string CreateSubFolder(string parent, string folderName)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(parent, folderName);
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
Assert.IsTrue(Directory.Exists(path), $"Fixture folder was not created at '{path}'.");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Entry names directly under <paramref name="folder"/>, ordered for stable comparison.</summary>
|
||||||
|
/// <remarks>Ordinal ordering on purpose: a case-only rename is a real PowerRename scenario.</remarks>
|
||||||
|
protected static IReadOnlyList<string> EntryNames(string folder) =>
|
||||||
|
Directory.EnumerateFileSystemEntries(folder)
|
||||||
|
.Select(Path.GetFileName)
|
||||||
|
.Where(name => name is not null)
|
||||||
|
.Select(name => name!)
|
||||||
|
.OrderBy(name => name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ---- module settings ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write PowerRename's own <c>power-rename-settings.json</c>. Both the shell extension (hosted in
|
||||||
|
/// Explorer) and the UI reload it from disk, so seeding the file is enough — no runner restart.
|
||||||
|
/// </summary>
|
||||||
|
protected static void ConfigureModuleSettings(
|
||||||
|
bool? showIcon = null,
|
||||||
|
bool? extendedContextMenuOnly = null,
|
||||||
|
bool? persistState = null,
|
||||||
|
bool? mruEnabled = null,
|
||||||
|
int? maxMruSize = null,
|
||||||
|
bool? useBoostLib = null)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(ModuleSettingsDirectory, "power-rename-settings.json");
|
||||||
|
var settings = File.Exists(path)
|
||||||
|
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
||||||
|
: new JsonObject();
|
||||||
|
|
||||||
|
foreach (var (name, value) in new (string Name, JsonNode? Value)[]
|
||||||
|
{
|
||||||
|
("ShowIcon", showIcon.HasValue ? JsonValue.Create(showIcon.Value) : null),
|
||||||
|
("ExtendedContextMenuOnly", extendedContextMenuOnly.HasValue ? JsonValue.Create(extendedContextMenuOnly.Value) : null),
|
||||||
|
("PersistState", persistState.HasValue ? JsonValue.Create(persistState.Value) : null),
|
||||||
|
("MRUEnabled", mruEnabled.HasValue ? JsonValue.Create(mruEnabled.Value) : null),
|
||||||
|
("MaxMRUSize", maxMruSize.HasValue ? JsonValue.Create(maxMruSize.Value) : null),
|
||||||
|
("UseBoostLib", useBoostLib.HasValue ? JsonValue.Create(useBoostLib.Value) : null),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
settings[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(ModuleSettingsDirectory);
|
||||||
|
File.WriteAllText(path, settings.ToJsonString(IndentedJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drop the persisted flags, last-run text, and MRU lists so a launch starts from defaults.</summary>
|
||||||
|
protected static void ClearPersistedRenameState()
|
||||||
|
{
|
||||||
|
foreach (var fileName in new[]
|
||||||
|
{
|
||||||
|
"power-rename-last-run-data.json",
|
||||||
|
"power-rename-ui-flags",
|
||||||
|
"search-mru.json",
|
||||||
|
"replace-mru.json",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var path = Path.Combine(ModuleSettingsDirectory, fileName);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Turn PowerRename on in the global settings, writing only when it is not already on.</summary>
|
||||||
|
private static void EnsureModuleEnabledInGlobalSettings()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(SettingsConfigHelper.PowerToysSettingsRoot, "settings.json");
|
||||||
|
var root = File.Exists(path)
|
||||||
|
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
||||||
|
: new JsonObject();
|
||||||
|
|
||||||
|
if (root["enabled"] is not JsonObject enabled)
|
||||||
|
{
|
||||||
|
enabled = new JsonObject();
|
||||||
|
root["enabled"] = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled["PowerRename"] is JsonValue value && value.TryGetValue<bool>(out var isEnabled) && isEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled["PowerRename"] = true;
|
||||||
|
Directory.CreateDirectory(SettingsConfigHelper.PowerToysSettingsRoot);
|
||||||
|
File.WriteAllText(path, root.ToJsonString(IndentedJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PowerRename window --------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launch the PowerRename window over <paramref name="paths"/> and wait until its item list is
|
||||||
|
/// populated. The item list is passed on the command line, matching how the shell extension
|
||||||
|
/// starts the UI for an Explorer selection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The returned session is process-scoped: a WinUI desktop app owns several top-level HWNDs, and
|
||||||
|
/// its flyouts and combo-box drop-downs live in windows of their own, so a single-HWND scope would
|
||||||
|
/// see only part of the UI.
|
||||||
|
/// </remarks>
|
||||||
|
protected Session LaunchPowerRename(params string[] paths)
|
||||||
|
{
|
||||||
|
Assert.IsTrue(paths.Length > 0, "PowerRename needs at least one item to rename.");
|
||||||
|
ClosePowerRenameWindows();
|
||||||
|
|
||||||
|
var exePath = SessionHelper.GetExecutablePath(PowerToysModule.PowerRename);
|
||||||
|
Assert.IsTrue(File.Exists(exePath), $"PowerRename UI executable not found at '{exePath}'.");
|
||||||
|
|
||||||
|
Step($"Launching PowerRename with {paths.Length} item(s) from '{Path.GetDirectoryName(paths[0])}'");
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = exePath,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(exePath)!,
|
||||||
|
UseShellExecute = true,
|
||||||
|
};
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (Process.Start(startInfo) ?? throw new InvalidOperationException($"Process.Start returned null for '{exePath}'."))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
var window = WindowsFinder.WaitForWindowByApp(
|
||||||
|
PowerRenameProcessName,
|
||||||
|
info => info.Width > 0 && info.Height > 0,
|
||||||
|
timeoutMS: WindowTimeoutMS);
|
||||||
|
Assert.IsNotNull(window, "The PowerRename window did not appear after launching it with the fixture items.");
|
||||||
|
|
||||||
|
Step("Waiting for the PowerRename window to become ready");
|
||||||
|
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 did not expose its search box. {DescribeSurface(session)}");
|
||||||
|
|
||||||
|
lastAppliedText.Clear();
|
||||||
|
TryBringPowerRenameForward();
|
||||||
|
|
||||||
|
var firstItemName = Path.GetFileName(paths[0].TrimEnd(Path.DirectorySeparatorChar));
|
||||||
|
Assert.IsTrue(
|
||||||
|
session.WaitFor(() => FindRowCheckBox(session, firstItemName) is not null, PreviewTimeoutMS, pollIntervalMS: 250),
|
||||||
|
$"The PowerRename item list never showed '{firstItemName}'. {DescribeSurface(session)}");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort raise of the PowerRename window. Foreground is not an authoritative readiness
|
||||||
|
/// signal here — UIA search and invoke never need it, and <c>Element.Click</c> raises the window
|
||||||
|
/// itself — while a scheduled interactive host can legitimately read <c>GetForegroundWindow()</c>
|
||||||
|
/// as 0 for seconds at a time. Steps that do need real input verify their own effect instead.
|
||||||
|
/// </summary>
|
||||||
|
protected void TryBringPowerRenameForward()
|
||||||
|
{
|
||||||
|
var settled = WaitHelper.WaitForStable(
|
||||||
|
observe: WindowControl.GetForegroundWindowInfo,
|
||||||
|
isMatch: info => info.ProcessName.Contains("PowerRename", StringComparison.OrdinalIgnoreCase),
|
||||||
|
timeoutMS: 8_000,
|
||||||
|
requiredConsecutiveMatches: 2,
|
||||||
|
pollIntervalMS: 250,
|
||||||
|
recover: _ => WindowControl.TryFocusByApp(PowerRenameProcessName));
|
||||||
|
|
||||||
|
if (!settled.Succeeded)
|
||||||
|
{
|
||||||
|
Step($"PowerRename did not take the foreground; continuing. Foreground: {WindowControl.GetForegroundWindowInfo()}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Window inventory plus a shallow UIA dump, so a readiness timeout says what was there.</summary>
|
||||||
|
private static string DescribeSurface(Session session)
|
||||||
|
{
|
||||||
|
var windows = string.Join(
|
||||||
|
"; ",
|
||||||
|
WindowsFinder.ListByApp(PowerRenameProcessName)
|
||||||
|
.Select(info => $"{info.ClassName} '{info.Title}' {info.Width}x{info.Height}"));
|
||||||
|
|
||||||
|
string tree;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tree = session.Inspect(depth: 8).ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tree = $"<inspect failed: {ex.Message}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Windows: [{windows}]. UIA tree: {tree[..Math.Min(tree.Length, 6_000)]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static void ClosePowerRenameWindows()
|
||||||
|
{
|
||||||
|
if (WindowControl.TryCloseByApp(PowerRenameProcessName, timeoutMS: 5_000) &&
|
||||||
|
WaitForProcess(PowerRenameProcessName, expected: false, timeoutMS: 2_000))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WindowControl.TryKillProcessTreeByNameAndWait(PowerRenameProcessName, timeoutMS: 10_000) ||
|
||||||
|
!WaitForProcess(PowerRenameProcessName, expected: false, timeoutMS: 2_000))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not stop '{PowerRenameProcessName}' during test cleanup.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static bool WaitForProcess(string processName, bool expected, int timeoutMS)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var processes = Process.GetProcessesByName(processName);
|
||||||
|
var running = processes.Length > 0;
|
||||||
|
foreach (var process in processes)
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (running == expected)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- driving the PowerRename UI --------------------------------------------------------------
|
||||||
|
|
||||||
|
protected void SetSearchText(Session window, string text) =>
|
||||||
|
SetAutoSuggestText(window, SearchBoxAutomationId, "search", text);
|
||||||
|
|
||||||
|
protected void SetReplaceText(Session window, string text) =>
|
||||||
|
SetAutoSuggestText(window, ReplaceBoxAutomationId, "replace", text);
|
||||||
|
|
||||||
|
private void SetAutoSuggestText(Session window, string automationId, string description, string text)
|
||||||
|
{
|
||||||
|
Step($"Setting the {description} box to '{text}'");
|
||||||
|
ApplyAutoSuggestText(window, automationId, description, text);
|
||||||
|
lastAppliedText[automationId] = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyAutoSuggestText(Session window, string automationId, string description, string text)
|
||||||
|
{
|
||||||
|
window.Find<TextBox>(By.AccessibilityId(automationId), timeoutMS: PreviewTimeoutMS).SetText(text);
|
||||||
|
|
||||||
|
// An empty box reports its placeholder through UIA, so only a non-empty value is verifiable.
|
||||||
|
if (text.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.IsTrue(
|
||||||
|
window.WaitFor(
|
||||||
|
() => window.Find<TextBox>(By.AccessibilityId(automationId), timeoutMS: 1_000).Value == text,
|
||||||
|
timeoutMS: 5_000,
|
||||||
|
pollIntervalMS: 200),
|
||||||
|
$"The {description} box did not accept the text '{text}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-type the search and replace terms. A programmatic value change can reach the box but not the
|
||||||
|
/// rename engine, leaving the preview computed from the previous terms; clearing first guarantees
|
||||||
|
/// a fresh change notification, because re-setting the same value raises none.
|
||||||
|
/// </summary>
|
||||||
|
private void ReapplySearchAndReplaceText(Session window)
|
||||||
|
{
|
||||||
|
foreach (var (automationId, text) in lastAppliedText.ToList())
|
||||||
|
{
|
||||||
|
var description = automationId == SearchBoxAutomationId ? "search" : "replace";
|
||||||
|
Step($"Re-applying the {description} box to nudge the rename engine");
|
||||||
|
ApplyAutoSuggestText(window, automationId, description, string.Empty);
|
||||||
|
ApplyAutoSuggestText(window, automationId, description, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected string GetSearchText(Session window) =>
|
||||||
|
window.Find<TextBox>(By.AccessibilityId(SearchBoxAutomationId)).Value;
|
||||||
|
|
||||||
|
protected string GetReplaceText(Session window) =>
|
||||||
|
window.Find<TextBox>(By.AccessibilityId(ReplaceBoxAutomationId)).Value;
|
||||||
|
|
||||||
|
protected void SetOptionCheckBox(Session window, string automationId, bool value)
|
||||||
|
{
|
||||||
|
Step($"Setting checkbox '{automationId}' to {value}");
|
||||||
|
var checkBox = window.Find<CheckBox>(By.AccessibilityId(automationId), PreviewTimeoutMS);
|
||||||
|
checkBox.SetCheck(value);
|
||||||
|
Assert.IsTrue(
|
||||||
|
checkBox.WaitForProperty("ToggleState", value ? "On" : "Off", timeoutMS: 5_000),
|
||||||
|
$"The '{automationId}' checkbox did not settle to {(value ? "checked" : "unchecked")}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Press a toolbar <c>ToggleButton</c> only when its state is wrong — a blind press on an
|
||||||
|
/// already-engaged toggle turns it off.
|
||||||
|
/// </summary>
|
||||||
|
protected void SetToggleButton(Session window, string automationId, bool value)
|
||||||
|
{
|
||||||
|
Step($"Setting toggle button '{automationId}' to {value}");
|
||||||
|
var button = window.Find<Button>(By.AccessibilityId(automationId), PreviewTimeoutMS);
|
||||||
|
if (GetToggleState(button) != value)
|
||||||
|
{
|
||||||
|
button.Invoke(msPostAction: 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.IsTrue(
|
||||||
|
button.WaitForProperty("ToggleState", value ? "On" : "Off", timeoutMS: 5_000),
|
||||||
|
$"The '{automationId}' toggle button did not settle to {(value ? "on" : "off")}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static bool IsToggleButtonOn(Session window, string automationId)
|
||||||
|
{
|
||||||
|
var button = window.Find<Button>(By.AccessibilityId(automationId), timeoutMS: 5_000);
|
||||||
|
return GetToggleState(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GetToggleState(Element element) =>
|
||||||
|
string.Equals(element.GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Select an entry of the "Apply to" combo box; its popup lives in a separate window.</summary>
|
||||||
|
protected void SelectApplyTo(Session window, string itemAutomationId)
|
||||||
|
{
|
||||||
|
Step($"Selecting rename scope '{itemAutomationId}'");
|
||||||
|
var comboBox = window.Find<ComboBox>(By.AccessibilityId("comboBox_renameParts"), timeoutMS: PreviewTimeoutMS);
|
||||||
|
comboBox.Invoke(msPostAction: 400);
|
||||||
|
window.Find<Element>(By.AccessibilityId(itemAutomationId), timeoutMS: PreviewTimeoutMS)
|
||||||
|
.Invoke(msPostAction: 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- preview -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>The per-item checkbox, whose UIA name is the item's original name.</summary>
|
||||||
|
protected static CheckBox? FindRowCheckBox(Session window, string originalName, int timeoutMS = 2_000) =>
|
||||||
|
FindExact<CheckBox>(window, originalName, timeoutMS);
|
||||||
|
|
||||||
|
/// <summary>Include or exclude a single preview row, guarded on its current toggle state.</summary>
|
||||||
|
protected void SetRowChecked(Session window, string originalName, bool value)
|
||||||
|
{
|
||||||
|
Step($"Setting the '{originalName}' row checkbox to {value}");
|
||||||
|
var row = FindRowCheckBox(window, originalName, PreviewTimeoutMS);
|
||||||
|
Assert.IsNotNull(row, $"The PowerRename item list did not contain a row for '{originalName}'.");
|
||||||
|
if (row!.IsChecked != value)
|
||||||
|
{
|
||||||
|
row.Invoke(msPostAction: 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.IsTrue(
|
||||||
|
row.WaitForProperty("ToggleState", value ? "On" : "Off", timeoutMS: 5_000),
|
||||||
|
$"The '{originalName}' row did not settle to {(value ? "checked" : "unchecked")}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wait until the preview stops offering <paramref name="renamedName"/> for any row.</summary>
|
||||||
|
protected void WaitForPreviewToDropName(Session window, string renamedName)
|
||||||
|
{
|
||||||
|
Step($"Waiting for the preview to drop '{renamedName}'");
|
||||||
|
Assert.IsTrue(
|
||||||
|
WaitForPreview(
|
||||||
|
window,
|
||||||
|
() => FindExact<TextBlock>(window, renamedName, timeoutMS: 500, comparison: StringComparison.Ordinal) is null),
|
||||||
|
$"The PowerRename preview still showed '{renamedName}'. {DescribeSurface(window)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wait until the preview column shows exactly <paramref name="renamedName"/> for some row.</summary>
|
||||||
|
protected void WaitForPreviewName(Session window, string renamedName)
|
||||||
|
{
|
||||||
|
Step($"Waiting for the preview to show '{renamedName}'");
|
||||||
|
Assert.IsTrue(
|
||||||
|
WaitForPreview(
|
||||||
|
window,
|
||||||
|
() => FindExact<TextBlock>(window, renamedName, timeoutMS: 500, comparison: StringComparison.Ordinal) is not null),
|
||||||
|
$"The PowerRename preview never showed '{renamedName}'. {DescribeSurface(window)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void WaitForOriginalCount(Session window, int count) =>
|
||||||
|
WaitForCount(window, OriginalCountAutomationId, count, "original");
|
||||||
|
|
||||||
|
/// <summary>Wait until the "will be renamed" badge reads <paramref name="count"/>.</summary>
|
||||||
|
protected void WaitForRenamedCount(Session window, int count)
|
||||||
|
=> WaitForCount(window, RenamedCountAutomationId, count, "renamed");
|
||||||
|
|
||||||
|
private void WaitForCount(Session window, string automationId, int count, string description)
|
||||||
|
{
|
||||||
|
var badge = "(" + count.ToString(CultureInfo.InvariantCulture) + ")";
|
||||||
|
Step($"Waiting for the {description} count badge to read '{badge}'");
|
||||||
|
Assert.IsTrue(
|
||||||
|
WaitForPreview(
|
||||||
|
window,
|
||||||
|
() => window.FindAll<TextBlock>(By.AccessibilityId(automationId), timeoutMS: 500)
|
||||||
|
.Any(element => element.Name.Equals(badge, StringComparison.Ordinal))),
|
||||||
|
$"The PowerRename header never reported {badge} {description} items. {DescribeSurface(window)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Poll a preview condition, re-typing the search and replace terms every few seconds so terms the
|
||||||
|
/// rename engine never received are corrected instead of waited out.
|
||||||
|
/// </summary>
|
||||||
|
private bool WaitForPreview(Session window, Func<bool> isReady)
|
||||||
|
{
|
||||||
|
const int requiredConsecutiveMatches = 3;
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(PreviewTimeoutMS);
|
||||||
|
var nextNudge = DateTime.UtcNow + TimeSpan.FromSeconds(6);
|
||||||
|
var consecutiveMatches = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (isReady())
|
||||||
|
{
|
||||||
|
if (++consecutiveMatches >= requiredConsecutiveMatches)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
consecutiveMatches = 0;
|
||||||
|
|
||||||
|
if (DateTime.UtcNow >= nextNudge && lastAppliedText.Count > 0)
|
||||||
|
{
|
||||||
|
ReapplySearchAndReplaceText(window);
|
||||||
|
nextNudge = DateTime.UtcNow + TimeSpan.FromSeconds(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- applying ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Press Apply and wait for the rename to land on disk. The Apply button is only enabled once at
|
||||||
|
/// least one item will be renamed, so that is the readiness signal for the press.
|
||||||
|
/// </summary>
|
||||||
|
protected void ApplyRename(Session window)
|
||||||
|
{
|
||||||
|
Assert.IsTrue(
|
||||||
|
window.WaitFor(() => FindApplyButton(window).IsEnabled, timeoutMS: PreviewTimeoutMS, pollIntervalMS: 250),
|
||||||
|
"The Apply button never became enabled, so PowerRename had nothing to rename.");
|
||||||
|
|
||||||
|
Step("Invoking Apply");
|
||||||
|
FindApplyButton(window).Invoke(msPostAction: 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Apply, then wait until <paramref name="folder"/> contains exactly <paramref name="expectedNames"/>.</summary>
|
||||||
|
protected void ApplyRenameAndAssertEntries(Session window, string folder, params string[] expectedNames)
|
||||||
|
{
|
||||||
|
ApplyRename(window);
|
||||||
|
AssertEntries(folder, expectedNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void AssertEntries(string folder, params string[] expectedNames)
|
||||||
|
{
|
||||||
|
var expected = expectedNames.OrderBy(name => name, StringComparer.Ordinal).ToList();
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(RenameTimeoutMS);
|
||||||
|
IReadOnlyList<string> actual;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
actual = EntryNames(folder);
|
||||||
|
if (actual.SequenceEqual(expected, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(250);
|
||||||
|
}
|
||||||
|
while (DateTime.UtcNow < deadline);
|
||||||
|
|
||||||
|
Assert.Fail(
|
||||||
|
$"'{folder}' contained [{string.Join(", ", actual)}] but [{string.Join(", ", expected)}] was expected " +
|
||||||
|
$"within {RenameTimeoutMS}ms of pressing Apply.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Element FindApplyButton(Session window) =>
|
||||||
|
window.Find<Element>(By.AccessibilityId(ApplyButtonAutomationId), timeoutMS: PreviewTimeoutMS);
|
||||||
|
|
||||||
|
// ---- lookup --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>By.Name</c> is a substring match in winappcli, so every lookup that must not collide with a
|
||||||
|
/// longer caption goes through an exact-name filter.
|
||||||
|
/// </summary>
|
||||||
|
protected static T? FindExact<T>(
|
||||||
|
Session session,
|
||||||
|
string name,
|
||||||
|
int timeoutMS = 5_000,
|
||||||
|
StringComparison comparison = StringComparison.OrdinalIgnoreCase)
|
||||||
|
where T : Element, new() =>
|
||||||
|
session.FindAll<T>(By.Name(name), timeoutMS)
|
||||||
|
.FirstOrDefault(element => element.Name.Equals(name, comparison));
|
||||||
|
|
||||||
|
// ---- cleanup -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private IReadOnlyList<Exception> RestoreModuleState()
|
||||||
|
{
|
||||||
|
var failures = new List<Exception>();
|
||||||
|
foreach (var (path, content) in moduleStateSnapshot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (content is null)
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllBytes(path, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add(new IOException($"Could not restore PowerRename state file '{path}'.", ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 5; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Directory.Exists(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="PowerRename.UITests.Next.app"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10+ feature support for unpackaged apps. -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
x:Uid="SelectAllCheckBox"
|
x:Uid="SelectAllCheckBox"
|
||||||
MinWidth="0"
|
MinWidth="0"
|
||||||
Margin="10,0,0,0"
|
Margin="10,0,0,0"
|
||||||
|
AutomationProperties.AutomationId="checkBox_selectAll"
|
||||||
Checked="SelectAll"
|
Checked="SelectAll"
|
||||||
Content=""
|
Content=""
|
||||||
IsChecked="True"
|
IsChecked="True"
|
||||||
@@ -64,7 +65,10 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<TextBlock x:Uid="TxtBlock_Original" FontWeight="Medium" />
|
<TextBlock x:Uid="TxtBlock_Original" FontWeight="Medium" />
|
||||||
<TextBlock Margin="4,0,0,0" FontWeight="Medium">
|
<TextBlock
|
||||||
|
Margin="4,0,0,0"
|
||||||
|
AutomationProperties.AutomationId="OriginalCount"
|
||||||
|
FontWeight="Medium">
|
||||||
<Run Text="(" /><Run Text="{x:Bind OriginalCount, Mode=OneWay}" /><Run Text=")" />
|
<Run Text="(" /><Run Text="{x:Bind OriginalCount, Mode=OneWay}" /><Run Text=")" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -77,7 +81,10 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Orientation="Horizontal">
|
Orientation="Horizontal">
|
||||||
<TextBlock x:Uid="TxtBlock_Renamed" FontWeight="Medium" />
|
<TextBlock x:Uid="TxtBlock_Renamed" FontWeight="Medium" />
|
||||||
<TextBlock Margin="4,0,0,0" FontWeight="Medium">
|
<TextBlock
|
||||||
|
Margin="4,0,0,0"
|
||||||
|
AutomationProperties.AutomationId="RenamedCount"
|
||||||
|
FontWeight="Medium">
|
||||||
<Run Text="(" /><Run Text="{x:Bind RenamedCount, Mode=OneWay}" /><Run Text=")" />
|
<Run Text="(" /><Run Text="{x:Bind RenamedCount, Mode=OneWay}" /><Run Text=")" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -88,6 +95,7 @@
|
|||||||
Height="32"
|
Height="32"
|
||||||
Margin="0,0,8,0"
|
Margin="0,0,8,0"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
|
AutomationProperties.AutomationId="FilterButton"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Content=""
|
Content=""
|
||||||
@@ -97,12 +105,14 @@
|
|||||||
<controls:RadioMenuFlyoutItem
|
<controls:RadioMenuFlyoutItem
|
||||||
x:Name="button_showAll"
|
x:Name="button_showAll"
|
||||||
x:Uid="ShowAll"
|
x:Uid="ShowAll"
|
||||||
|
AutomationProperties.AutomationId="button_showAll"
|
||||||
Click="ShowAll"
|
Click="ShowAll"
|
||||||
GroupName="Filter"
|
GroupName="Filter"
|
||||||
IsChecked="True" />
|
IsChecked="True" />
|
||||||
<controls:RadioMenuFlyoutItem
|
<controls:RadioMenuFlyoutItem
|
||||||
x:Name="button_showRenamed"
|
x:Name="button_showRenamed"
|
||||||
x:Uid="ShowOnly"
|
x:Uid="ShowOnly"
|
||||||
|
AutomationProperties.AutomationId="button_showRenamed"
|
||||||
Click="ShowRenamed"
|
Click="ShowRenamed"
|
||||||
GroupName="Filter" />
|
GroupName="Filter" />
|
||||||
</MenuBarItemFlyout>
|
</MenuBarItemFlyout>
|
||||||
@@ -201,7 +211,14 @@
|
|||||||
x:Uid="SearchBox"
|
x:Uid="SearchBox"
|
||||||
Height="40"
|
Height="40"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
ItemsSource="{x:Bind SearchMRU}" />
|
AutomationProperties.AutomationId="SearchAutoSuggestBox"
|
||||||
|
ItemsSource="{x:Bind SearchMRU}">
|
||||||
|
<AutoSuggestBox.TextBoxStyle>
|
||||||
|
<Style BasedOn="{StaticResource AutoSuggestBoxTextBoxStyle}" TargetType="TextBox">
|
||||||
|
<Setter Property="AutomationProperties.AutomationId" Value="textBox_search" />
|
||||||
|
</Style>
|
||||||
|
</AutoSuggestBox.TextBoxStyle>
|
||||||
|
</AutoSuggestBox>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
x:Uid="RegExButton"
|
x:Uid="RegExButton"
|
||||||
@@ -279,15 +296,18 @@
|
|||||||
<CheckBox
|
<CheckBox
|
||||||
x:Name="checkBox_regex"
|
x:Name="checkBox_regex"
|
||||||
x:Uid="CheckBox_RegEx"
|
x:Uid="CheckBox_RegEx"
|
||||||
Margin="2,6,0,0" />
|
Margin="2,6,0,0"
|
||||||
|
AutomationProperties.AutomationId="checkBox_regex" />
|
||||||
<CheckBox
|
<CheckBox
|
||||||
x:Name="checkBox_matchAll"
|
x:Name="checkBox_matchAll"
|
||||||
x:Uid="CheckBox_MatchAll"
|
x:Uid="CheckBox_MatchAll"
|
||||||
Margin="2,0,0,0" />
|
Margin="2,0,0,0"
|
||||||
|
AutomationProperties.AutomationId="checkBox_matchAll" />
|
||||||
<CheckBox
|
<CheckBox
|
||||||
x:Name="checkBox_case"
|
x:Name="checkBox_case"
|
||||||
x:Uid="CheckBox_Case"
|
x:Uid="CheckBox_Case"
|
||||||
Margin="2,0,0,0" />
|
Margin="2,0,0,0"
|
||||||
|
AutomationProperties.AutomationId="checkBox_case" />
|
||||||
|
|
||||||
<Rectangle
|
<Rectangle
|
||||||
Height="1"
|
Height="1"
|
||||||
@@ -305,7 +325,14 @@
|
|||||||
Height="40"
|
Height="40"
|
||||||
Padding="12,12,0,0"
|
Padding="12,12,0,0"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
ItemsSource="{x:Bind ReplaceMRU}" />
|
AutomationProperties.AutomationId="ReplaceAutoSuggestBox"
|
||||||
|
ItemsSource="{x:Bind ReplaceMRU}">
|
||||||
|
<AutoSuggestBox.TextBoxStyle>
|
||||||
|
<Style BasedOn="{StaticResource AutoSuggestBoxTextBoxStyle}" TargetType="TextBox">
|
||||||
|
<Setter Property="AutomationProperties.AutomationId" Value="textBox_replace" />
|
||||||
|
</Style>
|
||||||
|
</AutoSuggestBox.TextBoxStyle>
|
||||||
|
</AutoSuggestBox>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
x:Uid="FileCreationButton"
|
x:Uid="FileCreationButton"
|
||||||
@@ -519,11 +546,12 @@
|
|||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Width="200"
|
Width="200"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
|
AutomationProperties.AutomationId="comboBox_renameParts"
|
||||||
AutomationProperties.LabeledBy="{Binding ElementName=ApplyToLabel}"
|
AutomationProperties.LabeledBy="{Binding ElementName=ApplyToLabel}"
|
||||||
SelectedIndex="0">
|
SelectedIndex="0">
|
||||||
<ComboBoxItem x:Uid="RenameParts_FilenameAndExtension" />
|
<ComboBoxItem x:Uid="RenameParts_FilenameAndExtension" AutomationProperties.AutomationId="RenamePartsFilenameAndExtension" />
|
||||||
<ComboBoxItem x:Uid="RenameParts_FilenameOnly" />
|
<ComboBoxItem x:Uid="RenameParts_FilenameOnly" AutomationProperties.AutomationId="RenamePartsFilenameOnly" />
|
||||||
<ComboBoxItem x:Uid="RenameParts_ExtensionOnly" />
|
<ComboBoxItem x:Uid="RenameParts_ExtensionOnly" AutomationProperties.AutomationId="RenamePartsExtensionOnly" />
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<AppBarSeparator Margin="5,0,5,0" />
|
<AppBarSeparator Margin="5,0,5,0" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -531,6 +559,7 @@
|
|||||||
x:Uid="ToggleButton_IncludeFiles"
|
x:Uid="ToggleButton_IncludeFiles"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_includeFiles"
|
||||||
Content=""
|
Content=""
|
||||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||||
IsChecked="True" />
|
IsChecked="True" />
|
||||||
@@ -539,6 +568,7 @@
|
|||||||
x:Uid="ToggleButton_IncludeFolders"
|
x:Uid="ToggleButton_IncludeFolders"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_includeFolders"
|
||||||
Content=""
|
Content=""
|
||||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||||
IsChecked="True" />
|
IsChecked="True" />
|
||||||
@@ -547,6 +577,7 @@
|
|||||||
x:Uid="ToggleButton_IncludeSubFolders"
|
x:Uid="ToggleButton_IncludeSubFolders"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_includeSubfolders"
|
||||||
Content=""
|
Content=""
|
||||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||||
IsChecked="True" />
|
IsChecked="True" />
|
||||||
@@ -565,6 +596,7 @@
|
|||||||
x:Uid="ToggleButton_Lowercase"
|
x:Uid="ToggleButton_Lowercase"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_lowerCase"
|
||||||
Content="aa"
|
Content="aa"
|
||||||
FontWeight="Medium" />
|
FontWeight="Medium" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -572,6 +604,7 @@
|
|||||||
x:Uid="ToggleButton_Uppercase"
|
x:Uid="ToggleButton_Uppercase"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_upperCase"
|
||||||
Content="AA"
|
Content="AA"
|
||||||
FontWeight="Medium" />
|
FontWeight="Medium" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -579,6 +612,7 @@
|
|||||||
x:Uid="ToggleButton_TitleCase"
|
x:Uid="ToggleButton_TitleCase"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_titleCase"
|
||||||
Content="Aa"
|
Content="Aa"
|
||||||
FontWeight="Medium" />
|
FontWeight="Medium" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -586,6 +620,7 @@
|
|||||||
x:Uid="ToggleButton_Capitalize"
|
x:Uid="ToggleButton_Capitalize"
|
||||||
Height="32"
|
Height="32"
|
||||||
MinHeight="0"
|
MinHeight="0"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_capitalize"
|
||||||
Content="Aa Aa"
|
Content="Aa Aa"
|
||||||
FontWeight="Medium" />
|
FontWeight="Medium" />
|
||||||
<AppBarSeparator Margin="5,0,5,0" />
|
<AppBarSeparator Margin="5,0,5,0" />
|
||||||
@@ -594,6 +629,7 @@
|
|||||||
x:Name="toggleButton_enumItems"
|
x:Name="toggleButton_enumItems"
|
||||||
x:Uid="ToggleButton_EnumItems"
|
x:Uid="ToggleButton_EnumItems"
|
||||||
Height="32"
|
Height="32"
|
||||||
|
AutomationProperties.AutomationId="toggleButton_enumItems"
|
||||||
Content=""
|
Content=""
|
||||||
FontFamily="{ThemeResource SymbolThemeFontFamily}" />
|
FontFamily="{ThemeResource SymbolThemeFontFamily}" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -743,6 +779,7 @@
|
|||||||
Margin="0,0,12,1"
|
Margin="0,0,12,1"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
|
AutomationProperties.AutomationId="button_rename"
|
||||||
Click="button_rename_Click"
|
Click="button_rename_Click"
|
||||||
Style="{StaticResource AccentSplitButtonStyle}">
|
Style="{StaticResource AccentSplitButtonStyle}">
|
||||||
<SplitButton.KeyboardAccelerators>
|
<SplitButton.KeyboardAccelerators>
|
||||||
|
|||||||
Reference in New Issue
Block a user