diff --git a/.github/skills/ui-tests-local-vm/SKILL.md b/.github/skills/ui-tests-local-vm/SKILL.md new file mode 100644 index 000000000000..5fc143317520 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/SKILL.md @@ -0,0 +1,239 @@ +--- +name: ui-tests-local-vm +description: "Set up and run PowerToys UITest.Next suites in persistent local Hyper-V VMs driven over PowerShell Direct, created unattended from Windows install media. A module is done only when the full suite is green on both Windows 10 LTSC and Windows 11, in separate VMs, plus Windows 11 ARM64 on Windows on ARM hosts. Use for fast agentic UI-test iteration, reusable interactive desktops, non-admin scenarios, payload staging and refresh, evidence export, VM customization, checkpoint-based clean baselines, or hosts without nested virtualization. Keywords: Hyper-V, local VM, virtual machine, PowerShell Direct, Copy-VMFile, VMBus, checkpoint, unattend, autounattend, ISO, Windows 10, Windows 11, ARM64, Windows on ARM, UI tests, UITest.Next, winappcli, TRX." +license: MIT +--- + +# Local VM UI testing + +Run PowerToys `.Next` UI tests in a persistent, interactive Windows VM while keeping product and test +execution off the host. Use this skill as the execution complement to +[ui-tests-migration](../ui-tests-migration/SKILL.md). Restore the baseline checkpoint or recreate the +guest when clean-profile behavior must be validated. + +The guest is a Hyper-V virtual machine. Nothing runs nested, so the same scaffold works on x64 and on +Windows on ARM, where nested virtualization is unavailable to any Linux-hosted emulator. + +A module is done when the **full** suite is green on Windows 10 **and** on Windows 11, in two +separate VMs. Run Windows 10 Enterprise LTSC 2021 first because it gives the fastest feedback, then +run the same unfiltered suite on Windows 11. Differences in the shell, compositor, theming, and +timing break tests that contain nothing Windows 11-specific, and those are exactly the failures +worth catching locally instead of in CI. On a Windows on ARM host, Windows 11 ARM64 is the only +practical guest; run it with `-Platform ARM64` and get the Windows 10 half from an x64 host. + +Start guests with the default resource profile: 4 vCPUs and 8 GB RAM. Get the target suite fully +green before lowering resources with the `Constrained` profile (1 vCPU and 4 GB RAM). + +## How the host reaches the guest + +| Concern | Mechanism | +|---|---| +| Control channel | PowerShell Direct over VMBus. No listener, port, certificate, or firewall rule in the guest. | +| Bulk payload | `Copy-VMFile` over the Guest Service Interface, ~82 MB/s. The session copy is a fallback and stalls on archives near a gigabyte. | +| Exchange | Guest-local `C:\PowerToysUiTestExchange\`, mirrored by the controller. The host never shares a folder with the guest. | +| Host privilege | Hyper-V access: an elevated shell, or an account in the local **Hyper-V Administrators** group. Creating a guest additionally requires real elevation. | +| Console | `scripts/Get-VmConsoleImage.ps1` renders the framebuffer to PNG, so an agent can read boot and desktop state without VMConnect. | + +## When to use this skill + +Use it when asked to: + +- Create or migrate UI tests and iterate repeatedly without reprovisioning Windows each run. +- Validate Explorer, hotkey, WebView2, shell-extension, foreground, or composed-visual behavior in a + real interactive desktop. +- Run tests as a true standard user while retaining a separate administrator control channel. +- Reuse unchanged PowerToys, winappcli, and .NET payloads and refresh only changed archives. +- Keep a reproducible local VM baseline with optional .NET 10, WebView2, diagnostics, or `msvsmon`. +- Collect durable `status.json`, TRX, transcripts, logs, screenshots, and failure attachments. +- Compare revisions in one stable VM before confirming the result from a restored checkpoint. + +Do not treat a persistent VM as proof of clean-profile behavior. Caches, registrations, settings, and +first-run state survive between runs. Restore the baseline checkpoint or recreate the VM when those +are the behavior under test. + +## Relationship to other UI-test skills + +| Skill | Owns | +|---|---| +| `ui-tests-migration` | Test design, project scaffolding, framework APIs, assertions, lifecycle, and CI stability. | +| `ui-tests-local-vm` | Fast persistent-VM setup, deployment, interactive execution, evidence export, and iteration. | + +Do not modify stabilized tests merely to make the local VM green. First prove that the suite executes, +produces assertion-bearing TRX, and has a useful success rate. Classify environment-specific failures +separately unless the task explicitly asks for stabilization. + +## Guest OS policy + +- Two guests, two full suites. Windows 10 Enterprise LTSC 2021 (build 19044/21H2, newer than the + Windows 10 20H2 baseline) and Windows 11. Both must be fully green before a module is done. + LTSC is not available through Fido; `-Source Fido -Windows 10` automates Microsoft's official + mobile-user-agent ISO page and is the practical public default. The public ISO/MCT images are too + old for .NET 10 CET, so Setup Dynamic Update must bring Win10 to 1904x.5007+ before the baseline. + Use licensed Microsoft subscription media for LTSC when available, and always record the edition, + ISO hash, and installed full build ([references/setup.md §3](references/setup.md#3-get-windows-media)). +- Windows 10 runs first: it is the faster loop and surfaces most defects. Windows 11 then runs the + **same** suite, unfiltered - not only the tests that look Windows 11-specific. A test with no + Windows 11 content can still fail there, which is the whole reason for the second pass. +- Narrow filters belong to iteration and diagnosis. They are never the evidence for the Windows 11 + pass. +- Give each OS its own VM name, `vm.config.psd1`, VHDX, checkpoints, and exchange. Never upgrade or + repurpose the Windows 10 guest into the Windows 11 guest; the two baselines must stay independent. +- Surfaces that exist only on Windows 11, such as the tier-1 Explorer context menu, need no special + handling: the full Windows 11 run already covers them. +- On a Windows on ARM host, use a Windows 11 ARM64 guest with `-Platform ARM64`. Hyper-V does not + emulate a foreign architecture, so that host cannot supply the Windows 10 x64 pass - run it on an + x64 host and report both. +- A Windows 11 host requirement does not make Windows 11 the first guest target. +- `-Platform` is not cosmetic: it flows to the guest as the `platform` environment variable, names + visual baselines, and marks the run as pipeline-like. Use only `x64Win10`, `x64Win11`, or `ARM64`. + +## Required reads + +Read only what the task needs: + +1. [references/setup.md](references/setup.md) - host requirements, scaffolding, media acquisition, + unattended guest creation, credentials, standard-user desktop, and checkpoint baselines. +2. [references/agentic-loop.md](references/agentic-loop.md) - payload contract, controller usage, + focused-to-suite iteration, evidence, verdicts, and reset strategy. +3. [references/customization.md](references/customization.md) - persistent image customization, + .NET 10, WebView2, `msvsmon`, resource profiles, and golden-baseline guidance. +4. [references/troubleshooting.md](references/troubleshooting.md) - guest creation, PowerShell Direct, + interactive session, scheduled-task, focus, timeout, and export failures. +5. [references/shell-extensions-and-signing.md](references/shell-extensions-and-signing.md) - **read + for any shell-extension module** (context menu, preview/thumbnail handler). Why unsigned CI PR + builds cannot register a sparse MSIX (0% on CI), classic (registry-COM, signing-free) vs modern + (sparse-MSIX) surfaces, Debug vs Release/`NDEBUG` gating, runtime detection, and reproducing CI's + classic scenario on a local signed VM. +6. [ui-tests-migration](../ui-tests-migration/SKILL.md) - required whenever test code or framework + behavior is being created, migrated, or stabilized. + +## Default agentic cycle + +```mermaid +flowchart LR + A[Design or edit test] --> B[Host build] + B --> C[Package changed payload] + C --> D[Start or reuse VM] + D --> E[Probe standard-user desktop] + E --> F[Run focused test] + F --> G[Export status TRX evidence] + G --> H{Need test change?} + H -- Yes --> A + H -- No --> I[Run full suite on Win10] + I --> J[Run same full suite on Win11] + J --> K{Both fully green?} + K -- No --> A + K -- Yes --> L[Optional checkpoint restore] +``` + +Create and maintain this task list: + +```markdown +- [ ] 0. Verify host setup FIRST: `Initialize-LocalVmHost.ps1 -VmRoot -CheckOnly`. If it reports + IsReady=false, STOP and ask the user to run the elevated command it prints - Hyper-V group + membership, the DPAPI credential, and guest creation all need a human. Never autopilot past it +- [ ] 1. Read ui-tests-migration guidance for the target test surface +- [ ] 1a. Read the target module's dev docs — `doc/devdocs/modules/.md` (search `doc/devdocs/`, + including `common/`, if the exact file is missing) — for development-cycle gotchas such as + Release/`NDEBUG` registration gating, signed sparse-MSIX context menus, and Explorer restarts, + so a module's registration/deployment requirements do not surface as opaque test failures. + For shell-extension modules also read references/shell-extensions-and-signing.md. +- [ ] 2. Scaffold or verify the local VM - references/setup.md +- [ ] 3. Build product and test projects on the host to exit code 0 +- [ ] 4. Package a lean exchange and verify archive hashes +- [ ] 5. Run the controller with -PlanOnly and inspect its request/plan +- [ ] 6. Probe the non-admin interactive desktop before test execution +- [ ] 7. Run one focused test; read the controller result's `.Failed` array (non-passed tests + first + error line) instead of re-parsing TRX, and use `scripts/Invoke-GuestScript.ps1` for guest-state inspection +- [ ] 8. Diagnose the first controlling failure without weakening assertions +- [ ] 9. Rebuild and rerun with -ReuseStagedPayload +- [ ] 10. Widen to the full module suite on Windows 10 and report pass rate/root-cause groups +- [ ] 11. Run the same full suite in the Windows 11 VM; both must be green before the module is done +- [ ] 12. Restore the baseline checkpoint for clean-profile confirmation when required +``` + +## Quick start + +Host setup is a one-time, **human-only** step: Hyper-V group membership, the DPAPI guest credential, +and guest creation all need elevation or a password. Check it before anything else - this needs no +elevation and changes nothing: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVmHost.ps1 -VmRoot X:\PowerToysUiTestVm -CheckOnly +``` + +If it reports `IsReady=false`, stop and ask the user to run the elevated command it prints (see +[references/setup.md §0](references/setup.md#0-human-only-host-setup-one-command)). Otherwise scaffold +the VM directory outside the repository: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVm.ps1 ` + -DestinationRoot X:\PowerToysUiTestVm +``` + +The scaffold and its `shared` exchange can live on any volume, including a Dev Drive. Only the VHDX +and VM configuration paths, set separately in `vm.config.psd1`, should point at NTFS. + +Follow [references/setup.md](references/setup.md) to write the untracked `vm.config.psd1`, save the +administrator credential with Windows DPAPI, obtain install media, and create the guest with +`New-UiTestVm.ps1`. Then stage the archives described in +[references/agentic-loop.md](references/agentic-loop.md) and run: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win10 ` + -VmRoot X:\PowerToysUiTestVm ` + -ExchangeRoot X:\PowerToysUiTestVm\shared\PowerToysUiTests\MyModule ` + -TestExecutable MyModule.UITests.Next.exe ` + -Filter 'Name=MyModule.FocusedTest' ` + -Platform x64Win10 ` + -BuildLabel (git rev-parse HEAD) ` + -SuiteTimeout 15m ` + -TimeoutMinutes 25 ` + -ReuseStagedPayload +``` + +The controller starts the VM if needed, verifies the interactive standard-user token and desktop, +dispatches the shared guest runner through a limited interactive scheduled task, streams progress, +waits for parseable `status.json`, summarizes TRX, and leaves the persistent VM running by default. + +## Non-negotiable rules + +- Complete host setup before anything else and **never autopilot around it**. Hyper-V group + membership, the DPAPI guest credential, and guest creation are human-only: two need elevation that + no tool call can approve, one needs a password that must never reach a model. + `Initialize-LocalVmHost.ps1` performs all three; agents run it `-CheckOnly`, and on + `IsReady=false` report `BLOCKED`, print the elevated command it emits, and wait. Do not ask for a + password, do not substitute a weaker channel, do not proceed on a partial setup. + `Invoke-LocalVmUiTest.ps1` enforces the same check. +- Keep the guest's VHDX and VM configuration on NTFS. On this project's host, keeping them on a Dev + Drive wedged the VM management service twice - `vmms` at 0% CPU, even `Get-VM` hanging, host reboot + to recover - and moving to NTFS fixed it. This is an observation on one host, not a property of + ReFS: Hyper-V on plain ReFS is supported, so `-AllowReFsVolume` overrides the default refusal. The + scaffold and the exchange are unaffected and run fine on a Dev Drive. +- Build on the host; run PowerToys and tests only in the VM when host execution is prohibited. +- Finish on two green full suites: Windows 10 and Windows 11, in separate VMs. Windows 10 runs first + for speed; Windows 11 runs the same unfiltered suite, never a Win11-only subset. Narrow filters are + for iteration, not for sign-off. On a Windows on ARM host the ARM64 Windows 11 guest covers the + Windows 11 half and the Windows 10 half needs an x64 host. +- Establish a fully green correctness baseline with the default (4 vCPU / 8 GB) resources before + running the same tests under `Constrained` (1 vCPU / 4 GB) resources. +- Keep VM files and writable exchange folders outside the repository. +- Keep the guest's default inbound network posture. The control channel does not need connectivity, + so do not enable remoting, open ports, or attach the guest to a routable network for test dispatch. +- Use a DPAPI-protected credential file. Never put credentials in prompts, scripts, request JSON, + unattend files kept after install, source control, or command-line arguments. +- Run UI tests in an already logged-on standard-user desktop, never in session 0 or as `SYSTEM`. +- Keep a separate administrator account only for VM control and scheduled-task registration. +- Verify user, token integrity, Explorer presence, session ID, and display size before tests. +- Run product/tests from guest-local storage under `C:\PowerToysUiTestRun`. +- Use PowerShell 7 for interactive probe/test scheduled tasks. PowerShell Direct and OEM bootstrap + remain PS5.1-compatible by design; do not enable a remoting endpoint merely to use PS7. +- Reuse payloads by per-component hashes; refresh only changed tests/product/tools. +- Preserve assertions and visual thresholds. Classify VM-specific failures from evidence. +- Always parse TRX and require `total > 0` plus `executed == total`. A process exit code alone cannot + distinguish assertions, skipped/inconclusive tests, zero tests, timeout, or infrastructure failure. +- Keep the VM after normal runs for iteration. Stop it explicitly when idle; delete its VHDX only for + an intentional baseline reset. +- Final clean-profile claims require a restored baseline checkpoint or a recreated guest. Restore with + `Reset-LocalVm.ps1 -Restore`. diff --git a/.github/skills/ui-tests-local-vm/references/agentic-loop.md b/.github/skills/ui-tests-local-vm/references/agentic-loop.md new file mode 100644 index 000000000000..3c6fb1a04462 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/references/agentic-loop.md @@ -0,0 +1,241 @@ +# Agentic local-VM UI-test loop + +This loop assumes the test project follows `Microsoft.PowerToys.UITest.Next`, builds as a +Microsoft.Testing.Platform executable, and has already passed the `ui-tests-migration` design and +CI-stability checks. + +Run the full suite twice: on a Windows 10 Enterprise LTSC 2021 guest first, then the same unfiltered +suite on a separate Windows 11 guest. Both must be green. Windows 11 is not a filtered follow-up - +tests with no Windows 11 content still fail there for shell, compositor, and timing reasons. + +## 1. Build on the host + +Build only; do not launch PowerToys or tests on the host when the task forbids it. + +```pwsh +tools\build\build.cmd ` + -Path src\modules\\Tests\.UITests.Next ` + -Platform x64 -Configuration Debug + +git rev-parse HEAD +``` + +Exit code 0 is required. Record the build label before packaging. + +## 2. Create a lean exchange + +The exchange can be any host folder. Keeping it under the scaffold's `shared` folder keeps VM assets +together and out of the repository; the controller mirrors it into the guest at +`C:\PowerToysUiTestExchange\`: + +```text +\shared\PowerToysUiTests\\ +|-- ui-tests.zip +|-- powertoys-runtime.zip +|-- winappcli.zip +|-- dotnet-runtime.zip +|-- product-overlay.zip # optional +|-- MicrosoftEdgeWebView2RuntimeInstallerX64.exe # optional +`-- LocalVmResults\ +``` + +The controller writes each request and its durable evidence under `LocalVmResults`. + +Package archive contents directly: + +```pwsh +Compress-Archive -Path '\*' ` + -DestinationPath '\ui-tests.zip' -Force +Compress-Archive -Path '\*' ` + -DestinationPath '\powertoys-runtime.zip' -Force +``` + +**Build the product runtime in Release for any shell-extension test.** The runtime context-menu +registration for Image Resizer, File Locksmith, New+, and PowerRename is compiled behind +`#if defined(ENABLE_REGISTRATION) || defined(NDEBUG)`, so a **Debug** runtime silently omits it: the +module enables and logs normally, but the entry never appears in Explorer and menu assertions fail +with no obvious cause (for example, "Explorer did not show 'Resize with Image Resizer'"). CI ships +Release for this reason. If you must validate against a Debug runtime, rebuild only the affected +module DLL with `ENABLE_REGISTRATION` defined and overlay it via `product-overlay.zip`. + +Use the repository-pinned winappcli build and a private .NET runtime matching the test executable. +Even when .NET 10 is installed in the VM baseline, the private runtime remains the default for +reproducibility and revision comparison. + +The controller copies its bundled `templates/run-ui-tests.ps1`, computes per-component SHA-256 +hashes, writes a run-specific request, and never maps the repository or build output directly into +Windows. + +## 3. Validate the plan + +Always run the first request with `-PlanOnly`: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win10 ` + -VmRoot X:\PowerToysUiTestVm ` + -ExchangeRoot X:\PowerToysUiTestVm\shared\PowerToysUiTests\ ` + -TestExecutable .UITests.Next.exe ` + -Filter 'Name=' ` + -BuildLabel (git rev-parse HEAD) ` + -PlanOnly +``` + +Check: + +- `GuestExchangeRoot` in the plan is a path under `C:\PowerToysUiTestExchange`. +- Test, product, winappcli, and .NET hashes are present. +- The filter uses `Name=`, `Name~`, `FullyQualifiedName~`, or `TestCategory=`. +- No password, token, or source path appears in the request. + +## 4. Run one focused test + +Use the default VM resource profile (4 vCPUs and 8 GB RAM) while creating and stabilizing tests. Do +not begin on the constrained profile: first prove the test and product behavior with sufficient CPU +and RAM. + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win10 ` + -VmRoot X:\PowerToysUiTestVm ` + -ExchangeRoot X:\PowerToysUiTestVm\shared\PowerToysUiTests\ ` + -TestExecutable .UITests.Next.exe ` + -Filter 'Name=' ` + -Platform x64Win10 ` + -BuildLabel (git rev-parse HEAD) ` + -DesktopWidth 1920 -DesktopHeight 1080 ` + -SuiteTimeout 15m -TimeoutMinutes 25 +``` + +Before the guest runner starts, the controller dispatches a probe into the interactive account and +requires: + +- User is the configured standard user and is not an administrator. +- Session ID is greater than zero. +- Explorer exists in that session. +- Display dimensions match the request, unless both are zero. +- The guest exchange folder is accessible. + +The probe and test tasks execute under the provisioned PowerShell 7 `pwsh.exe`. The narrow +PowerShell Direct control channel remains inbox Windows PowerShell 5.1; no remoting endpoint or +firewall rule is enabled for PS7. + +Failure here is `BLOCKED`, not a test failure. + +Iterate on Windows 10 first; it is the faster loop. Point the same controller at the separate +Windows 11 VM and exchange with `-Platform x64Win11` for the second pass. Do not reuse the Windows 10 +guest disk as the Windows 11 guest. + +## 5. Parse evidence + +Each run writes: + +```text +LocalVmResults\localvm-\ +|-- controller-plan.json +|-- request.json +|-- desktop-probe.ps1 +|-- desktop-probe.json +|-- progress.json +|-- status.json +|-- local-vm-ui-tests.log +`-- TestResults\ + |-- .trx + `-- +``` + +The controller prints scalar TRX counters and per-test outcomes. Read both `status.json` and TRX: + +- Assertion-bearing TRX failures are `FAIL`. +- Skipped, inconclusive, or otherwise `NotExecuted` tests are `FAIL`; require `total > 0` and + `executed == total` even when the test process exits 0. +- Zero selected tests/MTP exit code 8 is `BLOCKED`. +- Missing desktop, control channel, archive, or status is `BLOCKED`. +- Proven display/profile/compositor differences are `ENVIRONMENT`. +- An N/M pass rate proves the execution loop ran, even when the task did not ask to stabilize tests. + +Do not modify an already stabilized suite merely because the local VM differs from CI. Report the +pass rate and group failures by controlling boundary first. + +## 6. Iterate incrementally + +After changing tests or product code: + +1. Build the touched project to exit code 0. +2. Replace only the corresponding archive. +3. Rerun the same focused filter with `-ReuseStagedPayload`. +4. Confirm `RefreshedComponents` contains only the changed component. +5. Widen only after the focused behavior is understood. + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + ` + -ReuseStagedPayload +``` + +The guest manifest persists under `C:\PowerToysUiTestRun`. Unchanged tests/product/winappcli/.NET +are not extracted again. WebView2 and other baseline tools remain installed. + +The VM stays running after each run. Use `-SkipStart` when it is already healthy, and +`-StopVmAfterRun` only when no further iteration is expected. + +## 7. Widen to the suite, on both guests + +Use a bounded category filter, first on the Windows 10 guest: + +```pwsh +-Filter 'TestCategory=' -SuiteTimeout 45m -TimeoutMinutes 60 +``` + +Report: + +- Executed, passed, failed, and error counts. +- Exact pass rate. +- Root-cause groups, not only test names. +- Guest user/session/display and payload fingerprint. +- Export errors independently from assertion failures. + +Then run the **same** filter against the Windows 11 guest with `-Platform x64Win11` and report that +evidence separately. Narrowing the Windows 11 run to Windows 11-specific tests does not satisfy this +step. The module is done only when both suites are fully green; a Windows 10 pass with an unrun or +red Windows 11 suite is an incomplete result, not a success. + +Once the complete target suite is green, stop the guest, then let the controller restart it with the +`Constrained` profile (1 vCPU and 4 GB RAM). Pass the guest's config explicitly when one VM root owns +multiple guests: + +```pwsh +pwsh \Stop-LocalVm.ps1 -ConfigPath \vm.config.win10.psd1 + +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win10 ` + -ConfigurationPath \vm.config.win10.psd1 ` + -ResourceProfile Constrained ` + -VmRoot -ExchangeRoot ` + -TestExecutable .UITests.exe ` + -Filter 'TestCategory=' -Platform x64Win10 ` + -ReuseStagedPayload +``` + +`Invoke-LocalVmUiTest.ps1` rejects a config whose `VmName` does not match, records the resource +profile in the request/result, and passes it to `Start-LocalVm.ps1`. Resource changes only apply +while the VM is off; stopping first is therefore required. Repeat separately on Windows 11. Keep the +default-profile TRX as the correctness baseline and classify failures that appear only under +constrained resources separately. + +## 8. Confirm clean-profile behavior + +A retained VM accumulates registry state, caches, thumbnail databases, WebView profiles, Settings, +and first-run suppressions. Choose one final confirmation based on risk: + +- Restore the baseline checkpoint with `Reset-LocalVm.ps1 -Restore`. A standard checkpoint includes + memory, so this returns to the captured logged-on desktop in seconds. +- Rebuild the guest from media with `New-UiTestVm.ps1 -Force` when the checkpoint itself is suspect. + +Do not call a retained run clean merely because the product archive was refreshed. + +## Revision comparison + +Hold the guest, Windows build, display, account, tools, filter, and timeouts constant. Change only the +intentional test/product archive and record both fingerprints. For a clean-baseline comparison, +restore the same checkpoint before each revision. diff --git a/.github/skills/ui-tests-local-vm/references/customization.md b/.github/skills/ui-tests-local-vm/references/customization.md new file mode 100644 index 000000000000..03578e74d0e2 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/references/customization.md @@ -0,0 +1,145 @@ +# Local VM customization + +## What is actually customized + +The guest is an ordinary Hyper-V virtual machine backed by one VHDX. Installing software does not +produce a new layer or image; it changes that persistent disk. Checkpoints are the only rollback +mechanism, so take one whenever you reach a state later runs should inherit. + +Use three levels deliberately: + +1. **VM configuration** (`vm.config.psd1`) - name, storage paths, disk size, CPU, RAM, switch, + accounts, architecture, locale, and the baseline checkpoint name. +2. **OEM baseline** - deterministic software and policy applied during Windows installation. +3. **Retained disk** - tools, user profiles, caches, and ad hoc diagnostics installed later. + +Pin the Windows media and every installer version for reproducibility. Keep OEM scripts in source +control, and keep `vm.config.psd1`, credentials, Windows media, licensed installers, checkpoints, and +the VHDX out of source control. The scaffold's `.gitignore` already excludes them. + +## OEM installation + +`New-UiTestVm.ps1` builds an answer-file ISO whose `FirstLogonCommands` copy the scaffold's `oem` +folder into `C:\OEM` and run `Provision-UiTestVm.ps1`. Extend that script or call additional scripts +from it. Every addition must be silent, idempotent, checksum-verified, and return a meaningful exit +code. + +Prefer offline, signed installers staged beside the script. Avoid downloading floating `latest` +artifacts during baseline creation. Record versions and SHA-256 values in a provisioning manifest. + +To update an existing VM, run the same idempotent script over PowerShell Direct with +`scripts/Invoke-GuestScript.ps1`, then re-take the baseline checkpoint. Recreate the guest instead +when installation-order or first-logon behavior matters. + +## .NET 10 + +Yes, .NET 10 can be part of the default baseline. + +- Place `dotnet-sdk-10*-win-*.exe` in `oem` to install the SDK and runtimes. +- If guest compilation is unnecessary, place `windowsdesktop-runtime-10*-win-*.exe` instead. +- Match the guest architecture: an ARM64 guest needs `-win-arm64` installers. +- Pin the exact installer version and verify its Microsoft-published checksum. +- Use Windows 10 Enterprise LTSC 2021 for the first maintained baseline on x64, and keep an equally + maintained Windows 11 baseline: both carry a full suite run. + +The UI-test controller still stages a private pinned `dotnet-runtime.zip` by default. This is +intentional: revision runs remain independent of servicing changes in the VM. The baseline runtime is +useful for diagnostics and manually launched tools. Rely on the system runtime only after extending +the guest contract to record and enforce its exact version. + +## WebView2 and other runtime prerequisites + +Place `MicrosoftEdgeWebView2RuntimeInstaller*.exe` in `oem` for WebView/Monaco-heavy suites. The +guest runner also supports a run-specific installer when the baseline lacks it. + +VC++ redistributables, Windows App SDK runtimes, certificates, fonts, media codecs, and test data can +be provisioned the same way. Install only what CI or the target user machine actually has; an overly +rich baseline can hide deployment defects. + +## Visual Studio Remote Debugger (`msvsmon`) + +`msvsmon` is optional. Normal UITest.Next execution requires no Visual Studio installation or remote +tools in the VM. The controller needs only PowerShell Direct, Task Scheduler, the interactive +desktop, winappcli, the product, tests, and the .NET runtime. + +For interactive debugging: + +1. Install the Remote Tools version compatible with the host Visual Studio, or copy the complete + matching Remote Debugger folder from the host installation. +2. Match the guest architecture. The x64 monitor can launch the 32-bit monitor when needed. +3. Attach the guest to a switch the host can reach - the `Default Switch` gives it a host-only NAT + address - and note that address; the control channel itself needs no network. +4. Add guest firewall rules for TCP 4026, and TCP 4025 only for WOW64 debugging. +5. Start `msvsmon` in the same `PTUser` desktop for normal user-process debugging. +6. Use `/nodiscovery` and connect directly to `:4026`; UDP 3702 discovery does not + need to be exposed. +7. Keep Windows authentication. Do not use no-auth mode outside an isolated disposable network. + +Run an elevated monitor only when attaching to an elevated or different-user process. That changes +the integrity boundary and must not be confused with the normal user-only test scenario. + +No additional Visual Studio components are required in the VM unless the guest must compile code. +Keep symbols on the host when possible; copy matching binaries/PDBs or point the debugger at the +staged build outputs. + +The standard controller does not start or stop `msvsmon`. Treat remote debugging as a developer +profile, not a dependency of unattended validation. + +## Golden baselines and reset + +A useful golden checkpoint has: + +- Windows fully serviced and activated as appropriate. +- OEM provisioning complete. +- `PTUser` auto-logon verified and the resolution task applied. +- Optional pinned runtimes installed. +- No PowerToys payload, test results, or module-specific cache in the guest work root. + +```pwsh +pwsh .\Reset-LocalVm.ps1 -List # show checkpoints +pwsh .\Reset-LocalVm.ps1 -CreateBaseline -CheckpointName 'webview2-installed' +pwsh .\Reset-LocalVm.ps1 -Restore -StartAfterRestore # back to the clean baseline +``` + +Standard checkpoints include memory, so restoring returns to the captured logged-on desktop in +seconds rather than a cold boot - far cheaper than reinstalling Windows for every clean-profile run. +Restoring a checkpoint is also more deterministic than accumulating repair scripts. + +Keep the Windows 10 and Windows 11 baselines as separate VMs with their own +configuration files and VHDX paths. Never upgrade one baseline in place to stand in for the other. + +Checkpoints consume disk. Budget at least twice `DiskSizeGB`, plus `MemoryStartupGB` per standard +checkpoint, and prune obsolete ones. + +## Custom Windows media + +`New-UiTestVm.ps1` accepts any Windows ISO through `-InstallMedia`, and +`scripts/Get-WindowsMedia.ps1` validates local media or resolves an official Microsoft retail link. +Host and guest architecture must match: Hyper-V does not emulate a foreign architecture, so an ARM64 +host builds ARM64 guests only. Use custom media when licensing, edition, language, servicing, or +enterprise policy requires it, and keep OEM provisioning independent of the media source. + +## Resource sizing + +Use the default profile until the target suite is fully green: 4 vCPUs and 8 GB RAM, from +`ProcessorCount` and `MemoryStartupGB` in `vm.config.psd1`. Establish correctness and stable timings +with this profile before investigating resource sensitivity. + +Only after the suite is green, restart with the constrained profile. Its defaults are 4 GB RAM and +1 vCPU, overridable with `ConstrainedMemoryStartupGB` and `ConstrainedProcessorCount`. Restricting +the VM to a single core is how this workflow reproduces slow-agent and CI-like timing pressure. Treat +failures introduced only by this second phase as resource-pressure findings; do not weaken assertions +to accommodate them. + +```pwsh +# Default correctness pass. +pwsh .\Start-LocalVm.ps1 -ResourceProfile Default -Wait + +# Post-green pressure iteration. +pwsh .\Start-LocalVm.ps1 -ResourceProfile Constrained -Wait +``` + +Run `Start-LocalVm.ps1 -PlanOnly` to inspect the resolved profile without touching the VM. The guest +runs directly on the platform hypervisor, so its memory comes straight from the host: leave several +gigabytes of headroom for the host and the build. Apply CPU pressure by lowering the VM's vCPU count, +not process affinity. diff --git a/.github/skills/ui-tests-local-vm/references/setup.md b/.github/skills/ui-tests-local-vm/references/setup.md new file mode 100644 index 000000000000..c06dc05d29e5 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/references/setup.md @@ -0,0 +1,409 @@ +# Local UI-test VM on Hyper-V + +Create a persistent, interactive Windows guest on the platform hypervisor and drive it from the host +over PowerShell Direct. This is the only supported VM backend for this skill; everything after the +guest exists is described in [agentic-loop.md](agentic-loop.md). + +What you get: + +- No nested virtualization, so the scaffold works on x64 and on Windows on ARM alike. +- No guest listener, port, certificate, or firewall rule. A guest with no network adapter at all + still works. +- Fast, repeatable clean baselines: a standard checkpoint restores a logged-on desktop in seconds. +- An agent-readable console: `Get-VmConsoleImage.ps1` writes a PNG of the framebuffer, and + `vmconnect.exe` is available for interaction. + +| Concern | Mechanism | +|---|---| +| Control channel | PowerShell Direct over VMBus | +| Exchange | Guest-local `C:\PowerToysUiTestExchange\`, mirrored by the controller | +| Payload transfer | `Copy-VMFile` over the Guest Service Interface, skipping archives whose SHA-256 already matches; the session copy is a fallback | +| Clean baseline | `Reset-LocalVm.ps1 -Restore` | +| Host shell | **Elevated**, or an account in the local Hyper-V Administrators group | + +## Host requirements + +- Windows 10/11 Pro, Enterprise, or Education with the Hyper-V feature enabled, or Windows Server. +- Permission to manage Hyper-V, granted once through [step 0](#0-grant-hyper-v-access-one-time-elevated). + The scripts probe the capability rather than the token shape, and stop with `BLOCKED` rather than + degrading the channel when neither route is available. +- **Guest storage on an NTFS volume.** On the host this skill was built against, keeping the VHDX on + a Dev Drive wedged the Hyper-V management service mid-operation: `vmms` and `vmwp` sat at 0% CPU, + later management calls never returned - including read-only ones such as `Get-VM` - and recovery + needed a `vmms` restart or a host reboot. Moving the guest to NTFS fixed it. + + Treat that as an observation on one host rather than a property of ReFS. Hyper-V on ReFS is a + supported and in places preferred configuration, and ReFS block cloning accelerates checkpoint + merges. A Dev Drive differs from plain ReFS mainly in that it attaches only an allow-listed set of + filesystem filters, which is a plausible - but unproven - way to strand a storage operation. + + `New-UiTestVm.ps1` therefore refuses ReFS by default and accepts `-AllowReFsVolume` as the + override; ReFS is a cheap proxy for "Dev Drive", since telling the two apart needs elevation. + Check with `(Get-Volume -DriveLetter D).FileSystemType`. + + This applies only to `VhdPath` and `VmPath`. The scaffold, the `shared` exchange, and the staged + archives are ordinary file I/O and run fine on a Dev Drive. +- Free disk for the guest disk plus checkpoints. Budget at least twice `DiskSizeGB`. A standard + checkpoint also stores the guest's memory, so add `MemoryStartupGB` on top. +- Host and guest architecture must match. Hyper-V does not emulate a foreign architecture, so an + ARM64 host builds ARM64 guests only. +- **The Visual C++ redistributable, for MP4 capture.** The harness records each test with + ScreenRecorderLib, a mixed-mode assembly importing `VCRUNTIME140`/`MSVCP140`; a clean Windows image + has neither, so video is skipped (the harness now prints why). `Initialize-LocalVmHost.ps1` + downloads the architecture-matched Microsoft-signed redistributable, verifies its Authenticode + signer, and stages it under `oem`; provisioning installs it automatically and + `ProvisioningReady.json` reports `ScreenRecordingSupported`. The controller also repairs an + existing guest from that verified payload before a run. +- **PowerShell 7 for guest-side orchestration.** The setup helper downloads the pinned official MSI, + verifies both its published release SHA-256 and Microsoft Authenticode signer, and stages it under + `oem`. Provisioning installs it with PS remoting disabled; the controller repairs existing guests + and runs desktop-probe/test scheduled tasks under `pwsh.exe`. + + PowerShell Direct and OEM bootstrap still use inbox Windows PowerShell 5.1. That is intentional: + registering a PS7 remoting endpoint would weaken the no-remoting posture, and provisioning must + work before PS7 exists. Keep every `Invoke-Command -VMName` scriptblock PS5.1-compatible; PS7 + removes that constraint from the much larger interactive runner. + +> **ARM64 guests need ARM64 payloads.** An ARM64 guest needs ARM64 PowerToys, test, winappcli, .NET, +> and WebView2 payloads, and it must be run with `-Platform ARM64` so visual baselines resolve. + +## 0. Human-only host setup (one command) + +Three prerequisites gate every agent-driven run, and **an agent can perform none of them**: two need +elevation, which no tool call can approve, and one needs a password, which must never be routed +through a model. `Initialize-LocalVmHost.ps1` does all three, reports what is already in place, +performs only what is missing, and is safe to re-run. It also refreshes copied scaffold scripts from +the current skill templates before mutating anything, stages VC++ and PowerShell 7 prerequisites, +and verifies Windows 10's .NET 10 CET floor before recapturing the baseline. + +| # | Prerequisite | Why a human | +|---|---|---| +| 1 | Membership in the local **Hyper-V Administrators** group | Elevation; takes effect only after signing out and back in | +| 2 | DPAPI **guest administrator credential** | A password typed straight into the prompt | +| 3 | The **guest** itself (`New-UiTestVm.ps1`) | Elevation, to read the media and create the virtual disk | + +Scaffold ([step 1](#1-scaffold)) and obtain media ([step 3](#3-get-windows-media)) first, then run +this once from an **elevated PowerShell 7** terminal: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVmHost.ps1 ` + -VmRoot C:\PowerToysUiTestVm ` + -InstallMedia C:\PowerToysUiTestVm\media\Win11_25H2_English_x64.iso +``` + +If the account was not yet in Hyper-V Administrators, the script adds it and stops: group membership +is baked into the logon token, so it **signs out and back in, then re-runs** to finish steps 2 and 3. +Everything after that is unattended. + +### Agents: check, then stop + +Run it with `-CheckOnly`. It needs no elevation, changes nothing, prints a status table, and exits +non-zero when something is missing: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVmHost.ps1 -VmRoot C:\PowerToysUiTestVm -CheckOnly +``` + +```text +Local UI-test VM host setup - PowerToysUiTest-Win11 + [ok] Hyper-V access ok + [missing] Guest credential missing: ...\admin.credential.xml + [ok] Guest ok (State=Running) +``` + +When it reports `IsReady=false`, **stop and ask the user to run the elevated command it prints.** Do +not autopilot around it, do not ask for a password, and do not substitute a weaker channel. +`Invoke-LocalVmUiTest.ps1` enforces the same check and throws `BLOCKED` with the same instruction. + +Useful switches: `-CheckOnly`, `-SkipScaffoldRefresh`, `-SkipVcRedist`, `-SkipPowerShell`, `-SkipWindowsUpdate`, +`-SkipGroupMembership`, `-SkipCredential`, `-SkipGuestCreation`, `-Account` (defaults to the current +user), `-Force`, `-AllowReFsVolume`. + +### Verifying by hand + +```pwsh +whoami /groups | Select-String 'Hyper-V Administrators' # must print the group +Get-VM # must not throw a permission error +``` + +`Get-VM: You do not have the required permission to complete this task` means the membership is +missing or the session predates it. Membership alone covers the whole run loop - scaffold, start and +stop, checkpoint reset, PowerShell Direct, `Copy-VMFile`; only **creating** a guest additionally needs +an elevated terminal. + +## 1. Scaffold + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVm.ps1 ` + -DestinationRoot X:\PowerToysUiTestVm +``` + +```text +X:\PowerToysUiTestVm\ +|-- vm.config.example.psd1 +|-- New-UiTestVm.ps1 +|-- Start-LocalVm.ps1 +|-- Stop-LocalVm.ps1 +|-- Reset-LocalVm.ps1 +|-- .gitignore +|-- unattend\unattend.xml.template +|-- oem\Provision-UiTestVm.ps1 +`-- shared\ +``` + +Copy `vm.config.example.psd1` to `vm.config.psd1` and set the VM name, storage paths, resources, and +`ProcessorArchitecture`. The configuration never contains a password. + +## 2. Save the administrator credential first + +[Step 0](#0-human-only-host-setup-one-command) does this for you. The equivalent by hand, when you +want to rotate the password or stage it separately: + +`New-UiTestVm.ps1` reads the guest administrator password from a DPAPI-protected file so it never +appears in a command line, a configuration file, or a chat prompt. Type it directly into the prompt. + +```pwsh +$credentialRoot = Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm' +New-Item $credentialRoot -ItemType Directory -Force | Out-Null +Get-Credential -UserName PTAdmin -Message 'Local UI-test VM administrator' | + Export-Clixml (Join-Path $credentialRoot 'admin.credential.xml') +``` + +The file decrypts only for the same Windows user on the same host. + +## 3. Get Windows media + +```pwsh +# Validate media you already have. +pwsh .github\skills\ui-tests-local-vm\scripts\Get-WindowsMedia.ps1 ` + -Source Local -Path D:\media\Win11_25H2_English_x64.iso + +# Windows 11 (x64 or arm64). +pwsh .github\skills\ui-tests-local-vm\scripts\Get-WindowsMedia.ps1 ` + -Source Fido -Windows 11 -Architecture arm64 -DestinationRoot D:\media + +# Windows 10 for the second guest. Fido automates Microsoft's mobile-user-agent download page; +# the resulting file is hosted by software.download.prss.microsoft.com. +pwsh .github\skills\ui-tests-local-vm\scripts\Get-WindowsMedia.ps1 ` + -Source Fido -Windows 10 -Architecture x64 -DestinationRoot D:\media +``` + +| Source | Use it for | +|---|---| +| `Local` | An ISO you already downloaded. Reports the SHA-256 so a team can pin one baseline. | +| `Url` | A pinned Microsoft Evaluation Center link. Enterprise/LTSC evaluations live here. | +| `Fido` | Official retail links resolved through the GPL-3.0 helper used by Rufus. Covers **Windows 10 and 11**, and is the only public route that also resolves arm64 Windows 11. | + +### Which Windows 10 image + +The two public Microsoft routes are both valid but neither is current enough for this repository: + +| Official route | Image currently produced | Notes | +|---|---|---| +| Microsoft ISO page (mobile user agent), automated by Fido | `Win10_22H2_English_x64v1.iso`, **19045.2965** (May 2023) | Smallest automation surface; direct Microsoft CDN | +| Microsoft Media Creation Tool | **19045.3803** (December 2023 service refresh) | Tool requires UAC and interactive choices | + +.NET 10 needs Windows 10 **1904x.5007 or newer** for complete CET support. Either public image by +itself aborts with `0x80131506` / `Your Windows doesn't fully support CET`. The primary path is +therefore Microsoft media plus the answer file's supported Windows Setup `DynamicUpdate=true`, which +fetches the current cumulative update during installation before the first baseline. The VM needs a +network switch for this (the default `Default Switch` does). + +`Initialize-LocalVmHost.ps1` verifies the installed UBR before accepting the baseline. If Dynamic +Update could not reach 5007, it runs `Update-LocalVmGuest.ps1` as a fallback, handles reboots, and +recreates the baseline. Do not capture a Win10 baseline below that floor. + +Windows 10 **Enterprise LTSC 2021** (build 19044) remains the stricter baseline named in the guest-OS +policy. Its old Evaluation Center page is no longer a dependable public download route; use licensed +Microsoft 365 / Visual Studio subscription media when available, then let Dynamic Update apply the +same CET-floor validation. Record the edition, ISO hash, and installed full build in every run. + +The `Fido` source downloads the helper from a pinned tag and refuses to run it unless its SHA-256 +matches the value pinned in the script. Upstream publishes no Authenticode-signed script, so review +the upstream diff and update the tag and hash together when raising the pin. Fido is never vendored +into this repository. + +A prepared, generalized VHDX is not supported by this script: it always installs from media so that +Setup owns the disk layout and the boot configuration. + +## 4. Create the guest + +[Step 0](#0-human-only-host-setup-one-command) invokes this script for you. Call it directly when you +want `-ListImages`, `-PlanOnly`, or to rebuild an existing guest with `-Force`. + +The scaffold files are copies and can become stale when the skill is updated. Prefer the step-0 +helper, which refreshes them and executes the **source template** directly. If you intentionally run +the copied `X:\PowerToysUiTestVm\New-UiTestVm.ps1`, refresh first with +`Initialize-LocalVm.ps1 -DestinationRoot X:\PowerToysUiTestVm -Force`. + +```pwsh +# Inspect what the media contains. +pwsh .\New-UiTestVm.ps1 -InstallMedia D:\media\Win11_25H2_English_Arm64_v2.iso -ListImages + +# Build the guest. +pwsh .\New-UiTestVm.ps1 -InstallMedia D:\media\Win11_25H2_English_Arm64_v2.iso -ImageName 'Windows 11 Pro' +``` + +The script creates an empty virtual disk, generates an answer file, packs it with the OEM payload +into a small ISO, attaches both that and the installation media, and lets **Windows Setup install +from inside the guest**. Run `-PlanOnly` first to check the resolved configuration and confirm the +answer file renders, without touching Hyper-V. + +Do not be tempted to speed this up by applying the image with DISM and running `bcdboot` on the host. +That is what `Convert-WindowsImage` does, but its goal is *native-VHD boot*, so `bcdboot` records +`vhd=[X:]\path\to.vhdx` device references. Inside a virtual machine that file does not exist - the +VHDX is the disk - and the guest dies with `0xc000000e` before writing a single log line. It cannot +be repaired from the host either: `bcdedit` resolves drive letters through the host's view and +rewrites them straight back into `vhd=` references. + +Several answer-file details are derived from [Rufus](https://github.com/pbatard/rufus) (GPL-3.0, +`src/wue.c`), which solves the same problem for USB media: + +| Setting | Why it matters | +|---|---| +| `` | Setup rejects the answer file without a product key element, even an empty one. | +| `HideOnlineAccountScreens` + a local account | Skips the Microsoft-account wall. Preferred over the deprecated `SkipMachineOOBE`/`SkipUserOOBE`, which Microsoft warns can leave OOBE in an unexpected state. | +| Base64-obfuscated passwords | Windows appends the element name to the password before base64-encoding UTF-16LE, so no plaintext password reaches the media. | +| `PreventDeviceEncryption`, `TCGSecurityActivationDisabled` | The guest has a virtual TPM, so Windows 11 would otherwise silently BitLocker-encrypt the disk and make it unreadable offline. | +| `BypassNRO` in specialize | Removes the online-account requirement during OOBE. | + +The script also presses Enter on the guest's virtual keyboard through `Msvm_Keyboard` while Setup +starts, because installation media waits for "Press any key to boot from CD or DVD" and nothing +types it in an automated VM. + +When provisioning finishes the script detaches both optical drives, deletes the generated answer ISO, +and takes the `provisioned-baseline` checkpoint. Provisioning creates `PTUser`, removes it from +Administrators, grants it the work root, configures console auto-logon, and disables sleep. It does +**not** enable remoting or open any port: the control channel needs neither. + +`Set-UiTestAutoLogon.ps1` validates each generated PTUser credential with `LogonUser`, stores it as +the protected LSA `DefaultPassword` secret, and removes the readable Winlogon `DefaultPassword` and +finite `AutoLogonCount` values. The count in the unattended answer is only a bootstrap until OEM +provisioning replaces the administrator login with persistent PTUser `ForceAutoLogon`. +`Stop-LocalVm.ps1` revalidates and preserves that credential before a cold shutdown, so PTUser's +DPAPI-protected profile data remains decryptable. `Start-LocalVm.ps1` uses the same helper for a +one-time repair when no PTUser Explorer session appears; a new credential is generated only when +the account has no valid protected secret. Updating only the Winlogon registry identity while +leaving an older LSA password produces a bad-password console logon. + +Watch progress at any time without VMConnect: + +```pwsh +pwsh ..\..\scripts\Get-VmConsoleImage.ps1 -VmName PowerToysUiTest-Win11 -Path X:\evidence\console.png +``` + +## 5. Confirm the desktop baseline + +Provisioning registers a logon task that sets the interactive desktop to 1920x1080 through +`ChangeDisplaySettings`, because display settings belong to the interactive session and cannot be +applied from the PowerShell Direct session. No manual step is needed; verify it instead: + +```pwsh +pwsh ..\..\scripts\Get-VmConsoleImage.ps1 -VmName PowerToysUiTest-Win11 -Path X:\evidence\desktop.png +``` + +The controller's own probe is the authoritative check - it fails the run unless the interactive user +is the configured standard user, is not an administrator, has a session ID above zero, has Explorer +running, can reach the guest exchange, and matches the requested resolution. + +If you open the console interactively with `vmconnect.exe localhost "PowerToysUiTest-Win11"`, turn +**off** enhanced session mode in the View menu: it opens a second session and can displace the +console session where the standard user is logged on. + +Re-take the baseline whenever you change the guest in a way later runs should inherit: + +```pwsh +pwsh .\Reset-LocalVm.ps1 -CreateBaseline +``` + +## 6. Run tests + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win11 ` + -VmRoot X:\PowerToysUiTestVm ` + -ExchangeRoot X:\PowerToysUiTestVm\shared\PowerToysUiTests\MyModule ` + -TestExecutable MyModule.UITests.Next.exe ` + -Filter 'Name=MyModule.FocusedTest' ` + -Platform ARM64 ` + -BuildLabel (git rev-parse HEAD) ` + -ReuseStagedPayload +``` + +Use `-Platform ARM64` for an ARM64 guest. The value reaches the tests as the `platform` environment +variable, where `VisualAssert` builds baseline filenames from it (`__ARM64.png`) and any +non-empty value makes the framework consider itself in a pipeline. It is restricted to the names CI +uses - `x64Win10`, `x64Win11`, `ARM64` - because an unrecognised value resolves no baseline and fails +silently rather than loudly. + +The controller creates the guest exchange, grants `PTUser` access to it, copies only the archives +whose hash changed, writes the request, probes the interactive desktop, dispatches the shared guest +runner as a limited interactive scheduled task, streams progress, copies the evidence back to +`\LocalVmResults\`, and removes the guest copy of that run folder. + +Payloads move with `Copy-VMFile` over the Guest Service Interface, measured at ~82 MB/s. The +PowerShell Direct session copy is the fallback only: it manages ~17 MB/s and stalls outright on +archives approaching a gigabyte. + +## 6a. Shell-extension modules: sign the payload before packaging + +Modules with a modern Windows 11 context menu (Image Resizer, PowerRename, File Locksmith, New+) +register a sparse MSIX at module-enable time, which requires a signature chaining to a trusted root. +An unsigned package fails `0x800B0100` and the menu never appears - the tests then fail with +messages like "Explorer did not show the 'Resize with Image Resizer' command". + +Sign at **packaging** time, not after deployment. The guest runner extracts the product and runs the +tests in one step, so there is no point in between where the extracted `.msix` could be signed; a +post-deployment signing step costs an extra full run every time the product archive changes. + +```pwsh +# 1. Host: sign the staged product tree without trusting anything on the build machine. +.\.pipelines\signSparsePackages.ps1 ` + -PackageRoot X:\PowerToysUiTestPayload\product ` + -SkipLocalTrust -ExportCertificatePath X:\PowerToysUiTestPayload\pt-test-signer.cer + +# 2. Guest, once per VM: trust the exported public certificate. +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-GuestScript.ps1 ` + -VmName PowerToysUiTest-Win11 ` + -ScriptBlock { + foreach ($store in 'Cert:\LocalMachine\Root', 'Cert:\LocalMachine\TrustedPeople') { + Import-Certificate -FilePath C:\PowerToysUiTestTools\pt-test-signer.cer -CertStoreLocation $store + } + } +``` + +Then zip the product tree as usual. Every later re-stage carries signed packages, and the trust +anchor lives only inside the disposable guest. The certificate is valid for a year, so step 2 is not +repeated. See [shell-extensions-and-signing.md](shell-extensions-and-signing.md) for why signing - +rather than driving the classic menu - is the faithful fix. + +Inspect guest state at any time over the same channel: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-GuestScript.ps1 ` + -VmName PowerToysUiTest-Win11 ` + -ScriptBlock { Get-Process explorer | Select-Object Id, SessionId } +``` + +## 7. Baselines and resets + +```pwsh +pwsh .\Reset-LocalVm.ps1 -List # show checkpoints +pwsh .\Reset-LocalVm.ps1 -Restore -StartAfterRestore # back to the clean baseline +pwsh .\Reset-LocalVm.ps1 -CreateBaseline -CheckpointName 'webview2-installed' +``` + +Standard checkpoints include memory, so restoring returns to the captured desktop rather than a cold +boot. Use a restored checkpoint, not a long-lived mutated guest, for any clean-profile claim. + +`Stop-LocalVm.ps1 -Save` saves state instead of shutting down when you want the next run to resume +instantly. + +## Security notes + +- No inbound listener, no published port, and no certificate exist. The control channel is VMBus and + is reachable only by a local administrator on this host. +- Auto-logon necessarily stores a recoverable password inside the guest. Treat the guest as an + isolated test machine and never reuse either account elsewhere. +- Keep `vm.config.psd1`, the guest disk, checkpoints, and the credential file out of source control. + The scaffold's `.gitignore` already excludes them. diff --git a/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md b/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md new file mode 100644 index 000000000000..cc378d14137a --- /dev/null +++ b/.github/skills/ui-tests-local-vm/references/shell-extensions-and-signing.md @@ -0,0 +1,164 @@ +# Shell extensions & the CI signing constraint + +Read this before writing or verifying UI tests for any module with a **shell extension** +(context menu, preview handler, thumbnail provider, drag-drop handler). It is the knowledge that is +*not* obvious from the test framework and caused the most trial-and-error. + +## The one fact that matters most + +**CI PR-validation builds are UNSIGNED** (`codeSign:false`). Any test that depends on a +**sparse-MSIX-packaged** shell extension gets **0% on CI**, because the package cannot register +(`0x800B0100 TRUST_E_NOSIGNATURE`). A test that passes on your machine (where you self-signed or +installed a signed build) can therefore fail 100% on CI. + +## Preferred fix: sign the MSIX on CI and force-trust it + +The workarounds below (driving the classic menu, launching the exe directly) let a test pass on an +unsigned build, but they *do not exercise the modern Win11 tier-1 context menu* — the surface real +users see. The faithful fix is to give CI a genuinely **signed** package plus a **test-only trusted +root**, so registration succeeds and the tests drive the real end-user workflow. This is legitimate +because the trust anchor is scoped to the test agent and asserts no security — it just makes Windows +treat the CI-built package as sideload-installable, exactly like a developer self-signing locally. + +**Why signing (not Developer Mode) is required.** PowerToys registers each sparse package at +module-enable time with `PackageManager.AddPackageByUriAsync` (`src/common/utils/package.h` +`RegisterSparsePackage`) — a *packaged* deployment that demands a signature chaining to a trusted +root. Developer Mode / register-by-loose-manifest would only help if the product code called +`AddPackage -Register AppxManifest.xml`, which it does not. So the only route is: **sign the `.msix` +and trust the signer.** + +**Mechanism** (all three steps must happen *before* the module is enabled): + +1. Create a self-signed **code-signing** cert whose subject **exactly equals** the manifest + `Publisher` — every PowerToys context-menu package and the CmdPal `PowerToysSparse.msix` use + `CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US`. The private + key is generated on the agent and never leaves it; nothing is committed. +2. **Force-trust** it via the **machine** stores: import the public cert into `LocalMachine\Root` + **and** `LocalMachine\TrustedPeople` (a self-signed leaf is its own root, and AppX sideload + consults TrustedPeople). These import silently, and the CI test agent is elevated (it installs + machine-level), so it can write them. Do **not** import into `CurrentUser\Root` — the user Root + store raises a CryptoAPI consent dialog that fails non-interactively (`UI is not allowed in this + operation`), even when elevated. `CurrentUser\TrustedPeople` is silent and fine as an extra for + per-user deployment; a non-elevated run that cannot write `LocalMachine\Root` cannot establish + machine root trust silently. +3. `signtool sign /fd SHA256` every sparse `.msix` the product will register. + +A ready-to-use, publisher-aware implementation ships with this skill: +[`.pipelines/signSparsePackages.ps1`](../../../../.pipelines/signSparsePackages.ps1). It reads each +package's manifest publisher, mints/reuses+trusts a matching cert, and signs only packages that are +not already validly signed (so real framework packages like VCLibs are left alone). Point it at +whichever tree hosts the packages: + +```powershell +# buildNow (run-in-place) — sign the packages in the downloaded build tree: +.\.pipelines\signSparsePackages.ps1 -PackageRoot "$(Pipeline.Workspace)\$(TestArtifactsName)" + +# installed (buildNowSlim / official) — sign after install, before the test enables the module. +# Machine install lands in %ProgramFiles%\PowerToys; per-user install in %LOCALAPPDATA%\PowerToys: +.\.pipelines\signSparsePackages.ps1 ` + -PackageRoot "$env:ProgramFiles\PowerToys","$env:LOCALAPPDATA\PowerToys" ` + -RequiredPackage 'ImageResizerContextMenuPackage.msix' + +# local UI-test VM sideload — sign the deployed runtime: +.\.pipelines\signSparsePackages.ps1 -PackageRoot "C:\PowerToysUiTestRun\PowerToys" +``` + +**Where it is wired in CI.** This runs in `.pipelines/v2/templates/job-test-project.yml` after the +download/install steps and before **Run UI Tests**. It recursively searches the run-in-place artifact +and complete machine/per-user install roots. Windows 11/ARM64 Image Resizer and all-module jobs pass +`-RequiredPackage ImageResizerContextMenuPackage.msix`, so missing, unsigned, or untrusted setup fails +at the prerequisite instead of surfacing later as a product-test failure. Jobs that do not exercise +Image Resizer keep signing best-effort because their suites can guard unavailable modern packages: + +```yaml + - pwsh: | + $roots = @( + "$(Pipeline.Workspace)\$(TestArtifactsName)", + "$env:ProgramFiles\PowerToys", + "$env:LOCALAPPDATA\PowerToys") + if ($requiresImageResizer) { + & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" ` + -PackageRoot $roots -RequiredPackage 'ImageResizerContextMenuPackage.msix' + } else { + try { & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" -PackageRoot $roots } + catch { Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)" } + } + displayName: "Sign sparse MSIX packages (test trust)" +``` + +**Prerequisite:** `signtool.exe`. The script finds it across PATH, any `Windows Kits` install (all +versions, plus the App Certification Kit), and a restored SDK BuildTools NuGet package; as a last +resort it fetches the public `Microsoft.Windows.SDK.BuildTools` package from nuget.org, so an agent +without the SDK still works given outbound access. Verified end-to-end in a local Win11 +VM — the unsigned package fails `Add-AppxPackage` / `AddPackageByUriAsync` with `0x800B0100`, and +after `signSparsePackages.ps1` signs it and the cert is force-trusted (`LocalMachine\Root` + +`TrustedPeople`) the same registration succeeds and the package appears in `Get-AppxPackage`. + +**Caveat — CmdPal at install time.** The installer's custom action stages/registers +`PowerToysSparse.msix` *during* install (`installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp`), +before any test-time signing step runs. Signing after install still covers every module that +registers at **enable** time (ImageResizer / PowerRename / FileLocksmith / NewPlus). If CmdPal's own +packaged registration is the thing under test on an installed build, the package must instead be +signed at **build** time (self-sign in the build stage and publish the public `.cer` for the test +stage to trust) — the run-in-place `buildNow` path avoids this because nothing registers until the +test enables it. + +With this in place a test can drive the modern surface directly on CI. `ModernRegistered()` (below) +becomes a portability guard for *unsigned* environments rather than a reason to avoid the modern menu. + +## Two shell-extension tiers + +| Tier | Mechanism | Signing | Unsigned CI PR build | +|---|---|---|---| +| **Modern** (Win11 tier-1 context menu, `IExplorerCommand`) | sparse **MSIX** package | **required** | ❌ cannot register | +| **Classic** ("Show more options", Win10 default menu, most preview/thumbnail handlers) | **registry COM** (`HKCU\Software\Classes\...\SystemFileAssociations\\ShellEx\...`) | none | ✅ works | + +**Rule:** you have two options on an unsigned CI build. **(A, preferred)** sign the modern package +and force-trust it (see *Preferred fix* above) so the test drives the real Win11 tier-1 menu. +**(B, fallback)** drive the **signing-free surface** — the classic COM menu, or launch the module exe +directly with the files (the PowerRename pattern; the exe often has a CLI/`FilesArgument`). If you +take the fallback, only assert the modern/tier-1 surface when the package is *actually* registered, +so it runs on signed/official/installed builds only: + +```csharp +private static bool ModernRegistered() => + new Windows.Management.Deployment.PackageManager() + .FindPackagesForUser(string.Empty) + .Any(p => p.Id.Name.Contains("", StringComparison.OrdinalIgnoreCase)); +``` + +Then: `OpenContextMenu(useClassicMenu: !ModernRegistered())`, and gate modern assertions behind +`if (ModernRegistered())`. + +## Debug vs Release — why local != CI + +- The classic registry-COM handler is usually gated `#if defined(ENABLE_REGISTRATION) || defined(NDEBUG)`, + so it is **compiled out of local Debug builds** and present only in CI **Release** (`NDEBUG`). To + exercise the classic menu against a **local Debug** runtime, rebuild the extension DLL with + `ENABLE_REGISTRATION` added to its ``. +- The sparse `.msix` in build output is **unsigned**; registering it locally needs a self-signed cert + whose subject == the package `Publisher` **and** that cert trusted (admin). CI does neither. +- Both handlers typically **self-gate on the module's enabled flag** at query time (classic + `QueryContextMenu` returns `E_FAIL`, modern `GetState` returns `ECS_HIDDEN`), so the entry tracks + the Settings toggle without re-registration. +- Modules register handlers at **enable time** (runtime), and an **already-running Explorer will not + surface a freshly-registered handler until the shell restarts** — restart `explorer.exe` once after + enabling (see PreviewPane / File Explorer add-ons tests). + +## Reproduce CI's classic scenario on a local (signed) VM + +1. Rebuild the extension DLL with `ENABLE_REGISTRATION` and deploy it into the guest runtime + (`C:\PowerToysUiTestRun\PowerToys\WinUI3Apps\`). +2. Neutralize the sparse package so `enable()` cannot register it: rename its `.msix` and + `Get-AppxPackage ** | Remove-AppxPackage -AllUsers`. This mirrors CI's unsigned failure. +3. The `ModernRegistered()` detection now returns false → the tests drive the classic menu exactly as + CI does. Use `Invoke-GuestScript.ps1` for the guest-side steps. + +## Slow-agent / cross-arch robustness + +Shell-extension tests are especially prone to slow-agent and ARM64-only races that you often cannot +reproduce on a local VM (even constrained to 1 core, see customization.md — you cannot emulate ARM64 +on an x64 host, so reason from the failure video/screenshot). The robustness patterns — re-select +before every attempt, retryable transient popups, verify fixtures reached disk, and slow-path +timeouts — are test design and live with the rest of the Explorer/shell test guidance in +[ui-tests-migration explorer-shell-tests.md](../../ui-tests-migration/references/explorer-shell-tests.md). diff --git a/.github/skills/ui-tests-local-vm/references/troubleshooting.md b/.github/skills/ui-tests-local-vm/references/troubleshooting.md new file mode 100644 index 000000000000..9269aeee9f0c --- /dev/null +++ b/.github/skills/ui-tests-local-vm/references/troubleshooting.md @@ -0,0 +1,104 @@ +# Troubleshooting local VM UI tests + +Classify the first failed boundary before changing test code. + +## Host and guest lifecycle + +| Symptom | Boundary | Action | +|---|---|---| +| `Get-VM` / `New-PSSession -VMName` fails with `You do not have the required permission` | Host Hyper-V access | Rerun from an elevated PowerShell 7 terminal, or add the account to the local `Hyper-V Administrators` group (one-time elevated change, effective after signing out and back in). Creating a guest still needs full elevation because it partitions and mounts disks. | +| `Mount-VHD` fails `0x80070522`, or `Initialize-Disk`/`Get-WindowsImage` is denied | Guest creation without elevation | Group membership is not enough for disk and image APIs. Run `New-UiTestVm.ps1` from a genuinely elevated terminal. Report `BLOCKED` instead of looking for a workaround. | +| Hyper-V operations hang at 0% CPU, `vmms`/`vmwp` never return, and even `Get-VM` blocks | VHDX on a Dev Drive | Move `VhdPath`/`VmPath` to NTFS; the exchange can stay where it is. Observed on one host and fixed by the move - not a property of ReFS, which Hyper-V supports. `New-UiTestVm.ps1` refuses ReFS by default as a proxy for Dev Drive; `-AllowReFsVolume` overrides. Recovery usually needs a `vmms` restart or a host reboot; wrap suspect Hyper-V calls in `Start-Job` + `Wait-Job -Timeout` so a wedged service does not hang the agent. | +| Guest boots to `0xc000000e` after applying an image on the host | Native-VHD boot entries | Do not `bcdboot` a mounted VHDX from the host: the BCD keeps `vhd=[X:]\...` device references that only resolve in the host's drive view. Create the guest by running Windows Setup inside the VM from an answer-file ISO, which is what `New-UiTestVm.ps1` does. | +| Guest boots to Setup instead of the desktop | Answer file was not applied | Confirm the disk was built by `New-UiTestVm.ps1` rather than attached from an unprepared image, and read the guest's `C:\Windows\Panther\setupact.log`. | +| Setup shows a cancel prompt, or stops around 10% | Key injection overshoot | Installation media waits for "Press any key to boot from CD or DVD", so the script types Enter through `Msvm_Keyboard` - but only until the framebuffer brightens, and at most a bounded number of times. Do not send unbounded keystrokes; later Enters land on Setup's Cancel button. | +| Guest cleanly shuts down about hourly; System event 1074 names `wlms.exe` | Expired Windows evaluation | Check `slmgr.vbs /dlv` or `SoftwareLicensingProduct`. Do not bypass licensing enforcement. Recreate the VM with current evaluation media or a properly licensed Windows image. The controller blocks expired time-based evaluations before test dispatch. | +| Console shows a second, empty session | Enhanced session mode | Turn enhanced session off in the VMConnect View menu. It opens an RDP session that displaces the console session where the standard user is logged on. | +| Guest disk grows without bound | Accumulated checkpoints | `Reset-LocalVm.ps1 -List`, then remove obsolete checkpoints. Budget at least twice `DiskSizeGB` plus `MemoryStartupGB` per standard checkpoint. | + +## Control channel and desktop + +| Symptom | Boundary | Action | +|---|---|---| +| `New-PSSession -VMName` fails with a logon error | Control account/DPAPI file | Recreate the credential file with `Get-Credential \| Export-Clixml`, typed directly into the prompt. DPAPI files do not roam between host users or machines. | +| `New-PSSession -VMName` reports the guest is not ready | Guest still booting, or PowerShell Direct disabled | Read the console with `Get-VmConsoleImage.ps1`. PowerShell Direct needs the guest running and the Hyper-V integration services enabled; it does not need networking. | +| `Copy-VMFile` fails `The Guest Service Interface is not enabled` | Integration service off | `Enable-VMIntegrationService -VMName -Name 'Guest Service Interface'`. Without it, the controller falls back to the much slower session copy. | +| A large archive copy stalls near completion | Session-copy fallback on a big file | `Copy-Item -ToSession` stalls on archives approaching a gigabyte. Confirm `Copy-VMFile` is being used; run with `-Verbose` to see why it fell back. | +| Desktop probe times out | No logged-on standard user | Read the console image, verify `PTUser` is the active console user, Explorer is running, and the scheduled task uses `Interactive`/`Limited`. Refresh the scaffold and use the current stop/start scripts: `Set-UiTestAutoLogon.ps1` must update the protected LSA secret and remove stale Winlogon password/count values. | +| Probe says user is administrator | Wrong account/baseline | Remove the test user from Administrators and log on again. Do not accept `RunLevel=Limited` as proof when UAC is disabled; inspect the token as the probe does. | +| Probe reports wrong dimensions | Resolution task did not run | Provisioning registers a logon task that calls `ChangeDisplaySettings` in the interactive session; check `C:\PowerToysUiTestRun\set-resolution.json`. Display settings cannot be applied from the PowerShell Direct session. Use zero for both desktop parameters only for nonvisual tests. | + +## Run dispatch and evidence + +| Symptom | Boundary | Action | +|---|---|---| +| No `status.json` but the task ended | Guest runner/finalization | Inspect scheduled-task `LastTaskResult`, the guest-local transcript, and the request path. A completed UI is not a completion signal. | +| Interactive `powershell.exe` task stays `Running`, but a local `cmd.exe` probe writes immediately | PTUser PowerShell task host | Stop and unregister only the failed task. Stage and extract payloads through the administrator session, then run a guest-local `.cmd` as an `Interactive`/`Limited` PTUser task. Write the exit code and TRX locally and export one evidence archive afterwards. | +| `status.json` exists but is temporarily empty | Create/write race | Wait for parseable JSON with matching `RunId`; never finish on file existence alone. The controller already does this. | +| One attachment subtree fails export | Transient copy failure | Inspect `ExportErrors`. The shared runner uses bounded `robocopy` retries for directories and writes status even when an artifact cannot be copied. | +| Zero tests/MTP exit 8 | Filter | Qualify the filter with `Name=`, `Name~`, `FullyQualifiedName~`, or `TestCategory=`. Treat as `BLOCKED`. | +| Test process exits 0 but TRX has skipped/`NotExecuted` tests | Incomplete suite | Treat the run as `FAIL`. The controller and guest runner require `total > 0` and `executed == total`; inspect inconclusive messages and restore missing prerequisites instead of accepting the process exit code. | +| Reuse reports a missing manifest | First run or cleaned work root | Run once without `-ReuseStagedPayload`, then reuse. A recreated guest necessarily needs a full first stage. | +| Changed archive is not refreshed | Hash/request mismatch | Compare request SHA-256 values with the actual archives and inspect `RefreshedComponents`. Do not compare only apphost EXE hashes. | + +## Test behavior in the VM + +| Symptom | Boundary | Action | +|---|---|---| +| A legitimate winappcli UIA call is killed after 60 seconds on a resource-limited guest | Per-call process guard | The local-VM runner sets `WINAPP_CLI_INVOKE_TIMEOUT_SECONDS=180`. Increase it only for a measured slow guest call; accepted values are 1-3600 seconds, and command-specific `-t`/`--timeout` plus grace still takes precedence. | +| Visual baselines are never found, or the run behaves unexpectedly like CI | `-Platform` value | `-Platform` flows to the guest as `platform`, names baselines (`__.png`), and any non-empty value marks the run as pipeline-like. Use only `x64Win10`, `x64Win11`, or `ARM64`. | +| Win11 tier-1 command is absent and the module log reports MSIX registration error `0x800B0100` | Unsigned local context-menu package | Sign the local MSIX with a test certificate whose subject matches the manifest publisher, import only its public certificate into the guest machine `TrustedPeople` and `Root` stores, and verify `Get-AuthenticodeSignature` reports `Valid`. Sign at packaging time - see [setup.md](setup.md), step 6a. A successful classic COM registration does not validate the modern menu. | +| Windows Search or another shell surface owns foreground | Persistent desktop state | Dismiss/reset the shell state or restart the VM before rerunning. Classify as environment when the test is already stable in CI. | +| A fixture/helper console owns foreground while Explorer cannot become stable | Activating helper launch | Inspect the failure PNG/MP4 and `GetForegroundWindowInfo()` first. If the foreground PID is the fixture, make it non-activating from creation; do not loosen Explorer's foreground assertion. An Explorer-opened `.cmd` can create a console despite `start /b`, and hiding the first enumerated window is racy. Use a direct `CreateNoWindow` child when integrity permits, or a hidden medium-integrity launcher such as `WScript.Shell.Run(..., 0, False)`, then verify no main window/foreground ownership. | +| Exact-HWND foreground check fails, but PNG shows the target usable (or foreground is the same process under a new HWND / zero) | Foreground requirement is broader than the interaction | Check what happens next. Keep strict foreground for Explorer menus, SendInput, coordinates, and drags. For coordinate-free UIA search/invoke, focus can be best-effort: bind readiness to the live process/window and authoritative UIA element, and retain foreground details as diagnostics. | +| WebView/Monaco stays loading | WebView2/runtime/profile | Verify the baseline WebView2 version or stage the signed installer for the run. Preserve WebView logs and screenshots. | +| Remote debugger cannot connect | Firewall/monitor identity | Verify `msvsmon` is running in the guest and that the guest is reachable on the chosen adapter. See [customization.md](customization.md). | +| Tests run but some fail only in this VM | Profile/display/foreground/environment | Preserve TRX and media, report pass rate and failure groups, and compare guest user/session/display with CI. Do not edit stabilized tests unless asked. | +| VM state hides a first-run defect | Retained profile/cache | Restore the baseline checkpoint with `Reset-LocalVm.ps1 -Restore`, or rebuild the guest. | + +## Host diagnostics + +```pwsh +Get-VM PowerToysUiTest-Win11 | Format-List Name, State, Status, Uptime, ProcessorCount, MemoryAssigned +Get-VMIntegrationService -VMName PowerToysUiTest-Win11 | Select-Object Name, Enabled, PrimaryStatusDescription +Get-VMSnapshot -VMName PowerToysUiTest-Win11 | Select-Object Name, SnapshotType, CreationTime +(Get-Volume -DriveLetter C).FileSystemType # NTFS expected for VhdPath/VmPath +pwsh .github\skills\ui-tests-local-vm\scripts\Get-VmConsoleImage.ps1 ` + -VmName PowerToysUiTest-Win11 -Path X:\evidence\console.png +``` + +If a Hyper-V call may be wedged, bound it rather than blocking the agent: + +```pwsh +$job = Start-Job { Get-VM } +if (-not (Wait-Job $job -Timeout 30)) { 'BLOCKED: VMMS is not responding' } +``` + +## Guest control diagnostics + +Use the administrator DPAPI credential over PowerShell Direct: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-GuestScript.ps1 ` + -VmName PowerToysUiTest-Win11 ` + -ScriptBlock { + Get-ScheduledTask -TaskName 'PowerToysUiTest-*' -ErrorAction SilentlyContinue | + Select-Object TaskName, State, @{n='User';e={$_.Principal.UserId}}, @{n='RunLevel';e={$_.Principal.RunLevel}} + query user + Get-CimInstance Win32_Process -Filter "Name='explorer.exe'" | Select-Object ProcessId, SessionId + } +``` + +Do not launch UI tests directly from that session: it is not the interactive desktop. Use the limited +interactive scheduled task created by the controller. + +## Guest-local evidence before termination + +If a run appears hung, use the administrator session only to copy diagnostics into the result folder. +Do not kill Explorer or the test host until process state, foreground details, and the live transcript +are preserved. Let the guest runner's `finally` write status whenever possible. + +## Cleanup + +The controller unregisters its scheduled tasks. Stop the VM with `Stop-LocalVm.ps1`; delete the guest +disk only when destroying the baseline is intentional. Preserve run folders before resetting. diff --git a/.github/skills/ui-tests-local-vm/scripts/Get-VmConsoleImage.ps1 b/.github/skills/ui-tests-local-vm/scripts/Get-VmConsoleImage.ps1 new file mode 100644 index 000000000000..7095e8d36142 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Get-VmConsoleImage.ps1 @@ -0,0 +1,95 @@ +# 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. + +<# +.SYNOPSIS +Saves the Hyper-V guest console as a PNG so it can be read without VMConnect. + +.DESCRIPTION +A Hyper-V guest console is normally only visible through VMConnect, which an agent cannot read. The +hypervisor exposes the framebuffer through +Msvm_VirtualSystemManagementService.GetVirtualSystemThumbnailImage, which is enough to read boot +errors, Setup progress, and whether the expected desktop is on screen. + +The thumbnail is RGB565; this converts it to PNG. Requires Hyper-V access on the host. + +.EXAMPLE +pwsh ./Get-VmConsoleImage.ps1 -VmName PowerToysUiTest-Win11 -Path X:\evidence\console.png +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$VmName, + [Parameter(Mandatory)][string]$Path, + [ValidateRange(64, 1920)][int]$Width = 1024, + [ValidateRange(64, 1200)][int]$Height = 768 +) + +$ErrorActionPreference = 'Stop' + +$namespace = 'root\virtualization\v2' +$service = Get-CimInstance -Namespace $namespace -ClassName Msvm_VirtualSystemManagementService +$system = Get-CimInstance -Namespace $namespace -ClassName Msvm_ComputerSystem -Filter "ElementName='$VmName'" +if ($null -eq $system) { + throw "Virtual machine '$VmName' was not found." +} +$settings = Get-CimAssociatedInstance -InputObject $system -ResultClassName Msvm_VirtualSystemSettingData | + Where-Object { $_.VirtualSystemType -eq 'Microsoft:Hyper-V:System:Realized' } | + Select-Object -First 1 + +$result = Invoke-CimMethod -InputObject $service -MethodName GetVirtualSystemThumbnailImage -Arguments @{ + TargetSystem = [ciminstance]$settings + WidthPixels = [uint16]$Width + HeightPixels = [uint16]$Height +} +if ($result.ReturnValue -ne 0) { + throw "GetVirtualSystemThumbnailImage failed with return value $($result.ReturnValue)." +} +$rgb565 = $result.ImageData +if ($null -eq $rgb565 -or $rgb565.Length -eq 0) { + throw "The hypervisor returned an empty thumbnail for '$VmName'. The guest is probably off." +} + +Add-Type -AssemblyName System.Drawing +$bitmap = [System.Drawing.Bitmap]::new($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format24bppRgb) +try { + $data = $bitmap.LockBits( + [System.Drawing.Rectangle]::new(0, 0, $Width, $Height), + [System.Drawing.Imaging.ImageLockMode]::WriteOnly, + [System.Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { + $row = [byte[]]::new($data.Stride) + for ($y = 0; $y -lt $Height; $y++) { + for ($x = 0; $x -lt $Width; $x++) { + $index = (($y * $Width) + $x) * 2 + if ($index + 1 -ge $rgb565.Length) { break } + $pixel = [int]$rgb565[$index] -bor ([int]$rgb565[$index + 1] -shl 8) + $offset = $x * 3 + # 24bpp bitmaps are stored blue, green, red. + $row[$offset] = [byte]((($pixel -band 0x1F) * 255) / 31) + $row[$offset + 1] = [byte](((($pixel -shr 5) -band 0x3F) * 255) / 63) + $row[$offset + 2] = [byte](((($pixel -shr 11) -band 0x1F) * 255) / 31) + } + [Runtime.InteropServices.Marshal]::Copy( + $row, 0, [IntPtr]($data.Scan0.ToInt64() + ($y * $data.Stride)), $data.Stride) + } + } + finally { + $bitmap.UnlockBits($data) + } + + New-Item (Split-Path $Path -Parent) -ItemType Directory -Force | Out-Null + $bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) +} +finally { + $bitmap.Dispose() +} + +[pscustomobject]@{ + VmName = $VmName + Path = (Resolve-Path $Path).Path + Width = $Width + Height = $Height + Bytes = (Get-Item $Path).Length +} | ConvertTo-Json diff --git a/.github/skills/ui-tests-local-vm/scripts/Get-WindowsMedia.ps1 b/.github/skills/ui-tests-local-vm/scripts/Get-WindowsMedia.ps1 new file mode 100644 index 000000000000..189ef7fccabd --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Get-WindowsMedia.ps1 @@ -0,0 +1,186 @@ +# 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. + +<# +.SYNOPSIS +Obtains Windows installation media for the local UI-test guest from a public Microsoft source. + +.DESCRIPTION +Three sources are supported: + + Local Validate an ISO you already have and report its SHA-256. This is the default. + Url Download a pinned Microsoft URL, such as an Evaluation Center link, and verify its hash. + Fido Resolve an official Microsoft retail ISO link with Fido, the GPL-3.0 download helper used + by Rufus. Fido is fetched from a pinned tag and refused unless its SHA-256 matches the + hash pinned below, because upstream does not publish an Authenticode-signed script. Fido + is the only public route that also resolves arm64 Windows 11 media. It is downloaded on + demand and never vendored into this repository. + +The script never bypasses hash verification. If verification fails it stops and tells you to obtain +the media manually. + +.EXAMPLE +pwsh ./Get-WindowsMedia.ps1 -Source Fido -Windows 11 -Edition Pro -Architecture arm64 -UrlOnly + +.EXAMPLE +pwsh ./Get-WindowsMedia.ps1 -Source Fido -Windows 11 -Edition Pro -DestinationRoot D:\media + +.EXAMPLE +pwsh ./Get-WindowsMedia.ps1 -Source Local -Path D:\media\Win11_24H2_English_x64.iso +#> + +[CmdletBinding()] +param( + [ValidateSet('Fido', 'Url', 'Local')] + [string]$Source = 'Local', + [string]$DestinationRoot = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm-Media'), + + [string]$Windows = '11', + [string]$Release = 'Latest', + [string]$Edition, + [string]$Language = 'English', + [ValidateSet('x64', 'arm64')] + [string]$Architecture, + + [string]$Url, + [string]$Path, + [string]$ExpectedSha256, + + # Pin the helper. Review the script, then update both values together, before raising the tag. + [string]$FidoTag = 'v1.70', + [string]$FidoSha256 = '24C86067FA399D2FD75EF0693A2EC79CA8DB162827F808CAAC03541CBF640C13', + [switch]$UrlOnly +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this script with PowerShell 7 (pwsh).' +} + +if ([string]::IsNullOrWhiteSpace($Architecture)) { + $Architecture = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' } +} + +function Save-LargeFile { + param( + [Parameter(Mandatory)][string]$Uri, + [Parameter(Mandatory)][string]$Destination + ) + + New-Item (Split-Path $Destination -Parent) -ItemType Directory -Force | Out-Null + $bits = Get-Command Start-BitsTransfer -ErrorAction SilentlyContinue + if ($null -ne $bits) { + Start-BitsTransfer -Source $Uri -Destination $Destination -Description 'Windows media' + return + } + Invoke-WebRequest -Uri $Uri -OutFile $Destination +} + +function Get-MediaResult { + param( + [Parameter(Mandatory)][string]$IsoPath, + [string]$ResolvedFrom, + [string]$ResolvedHost + ) + + $item = Get-Item $IsoPath + $hash = (Get-FileHash $IsoPath -Algorithm SHA256).Hash + if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) -and $hash -ne $ExpectedSha256.ToUpperInvariant()) { + throw "SHA-256 mismatch for $IsoPath. Expected $ExpectedSha256 but found $hash." + } + return [pscustomobject]@{ + Path = $item.FullName + SizeGB = [math]::Round($item.Length / 1GB, 2) + Sha256 = $hash + Source = $ResolvedFrom + ResolvedHost = $ResolvedHost + Architecture = $Architecture + } +} + +switch ($Source) { + 'Local' { + if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path $Path -PathType Leaf)) { + throw 'Specify an existing ISO with -Path when using -Source Local.' + } + Get-MediaResult -IsoPath $Path -ResolvedFrom 'Local' | ConvertTo-Json + return + } + + 'Url' { + if ([string]::IsNullOrWhiteSpace($Url)) { + throw 'Specify -Url when using -Source Url.' + } + if ($UrlOnly) { + [pscustomobject]@{ Url = $Url; Architecture = $Architecture } | ConvertTo-Json + return + } + $fileName = [IO.Path]::GetFileName(([uri]$Url).AbsolutePath) + if ([string]::IsNullOrWhiteSpace($fileName)) { + $fileName = 'windows-media.iso' + } + $destination = Join-Path $DestinationRoot $fileName + Write-Host "Downloading $fileName..." + Save-LargeFile -Uri $Url -Destination $destination + Get-MediaResult -IsoPath $destination -ResolvedFrom 'Url' -ResolvedHost ([uri]$Url).DnsSafeHost | ConvertTo-Json + return + } +} + +$fidoRoot = Join-Path $DestinationRoot "fido-$FidoTag" +$fidoPath = Join-Path $fidoRoot 'Fido.ps1' +if (-not (Test-Path $fidoPath -PathType Leaf)) { + New-Item $fidoRoot -ItemType Directory -Force | Out-Null + $fidoUri = "https://raw.githubusercontent.com/pbatard/Fido/$FidoTag/Fido.ps1" + Write-Host "Downloading the Fido helper from the pinned tag $FidoTag..." + Invoke-WebRequest -Uri $fidoUri -OutFile $fidoPath +} + +$signature = Get-AuthenticodeSignature $fidoPath +$fidoHash = (Get-FileHash $fidoPath -Algorithm SHA256).Hash +if ($fidoHash -ne $FidoSha256.ToUpperInvariant()) { + Remove-Item $fidoPath -Force -ErrorAction SilentlyContinue + throw "BLOCKED: the Fido helper at tag $FidoTag hashed $fidoHash instead of the pinned $FidoSha256. Review the upstream change and pass -FidoSha256 deliberately, or download Windows media manually from the Microsoft Evaluation Center." +} +Write-Host "Fido $FidoTag verified by SHA-256 (Authenticode status: $($signature.Status))." + +$fidoArguments = @( + '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $fidoPath, + '-Win', $Windows, '-Rel', $Release, '-Lang', $Language, '-Arch', $Architecture, '-GetUrl' +) +if (-not [string]::IsNullOrWhiteSpace($Edition)) { + $fidoArguments += @('-Ed', $Edition) +} + +# Fido targets Windows PowerShell and instantiates WinForms types when it is not in command-line mode. +$fidoOutput = & powershell.exe @fidoArguments 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "Fido failed with exit code $LASTEXITCODE. $($fidoOutput | Out-String)" +} +$resolvedUrl = @($fidoOutput | Where-Object { $_ -match '^https://' }) | Select-Object -Last 1 +if ([string]::IsNullOrWhiteSpace($resolvedUrl)) { + throw "Fido did not return a download URL. $($fidoOutput | Out-String)" +} +$resolvedUri = [uri]$resolvedUrl +$resolvedHost = $resolvedUri.DnsSafeHost.ToLowerInvariant() +if ($resolvedHost -ne 'microsoft.com' -and -not $resolvedHost.EndsWith('.microsoft.com', [StringComparison]::Ordinal)) { + throw "BLOCKED: the mobile-user-agent resolver returned non-Microsoft host '$resolvedHost'. Refusing to download $resolvedUrl" +} + +if ($UrlOnly) { + [pscustomobject]@{ + Url = $resolvedUrl + ResolvedHost = $resolvedHost + Architecture = $Architecture + FidoTag = $FidoTag + } | ConvertTo-Json + return +} + +$fileName = [IO.Path]::GetFileName(([uri]$resolvedUrl).AbsolutePath) +$destination = Join-Path $DestinationRoot $fileName +Write-Host "Downloading $fileName (several GB)..." +Save-LargeFile -Uri $resolvedUrl -Destination $destination +Get-MediaResult -IsoPath $destination -ResolvedFrom "Microsoft ISO page via verified Fido $FidoTag" -ResolvedHost $resolvedHost | ConvertTo-Json diff --git a/.github/skills/ui-tests-local-vm/scripts/Initialize-LocalVm.ps1 b/.github/skills/ui-tests-local-vm/scripts/Initialize-LocalVm.ps1 new file mode 100644 index 000000000000..466c512ed791 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Initialize-LocalVm.ps1 @@ -0,0 +1,66 @@ +# 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. + +<# +.SYNOPSIS +Scaffolds a local Hyper-V UI-test VM directory for persistent PowerToys UI-test execution. + +.DESCRIPTION +Copies the Hyper-V VM lifecycle scripts, the unattend template, and the OEM provisioning payload +into a working directory. Everything runs on the platform hypervisor, so no nested virtualization is +needed and the scaffold works on x64 and on Windows on ARM alike. + +.EXAMPLE +pwsh ./Initialize-LocalVm.ps1 -DestinationRoot X:\PowerToysUiTestVm +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [string]$DestinationRoot, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this script with PowerShell 7 (pwsh).' +} + +$templateRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\templates\vm')) +$oemTemplateRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\templates\oem')) +$destination = [IO.Path]::GetFullPath($DestinationRoot) +if (-not (Test-Path $templateRoot -PathType Container)) { + throw "VM templates were not found: $templateRoot" +} +if (-not (Test-Path $oemTemplateRoot -PathType Container)) { + throw "OEM templates were not found: $oemTemplateRoot" +} + +if (Test-Path $destination -PathType Container) { + $existingItems = @(Get-ChildItem $destination -Force) + if ($existingItems.Count -gt 0 -and -not $Force) { + throw "Destination is not empty: $destination. Pass -Force to merge and overwrite template files." + } +} + +if ($PSCmdlet.ShouldProcess($destination, 'Scaffold the local Hyper-V UI-test VM')) { + New-Item $destination -ItemType Directory -Force | Out-Null + Copy-Item (Join-Path $templateRoot '*') $destination -Recurse -Force + New-Item (Join-Path $destination 'oem') -ItemType Directory -Force | Out-Null + Copy-Item (Join-Path $oemTemplateRoot '*') (Join-Path $destination 'oem') -Recurse -Force + New-Item (Join-Path $destination 'shared') -ItemType Directory -Force | Out-Null +} + +[pscustomobject]@{ + VmRoot = $destination + ConfigurationTemplate = (Join-Path $destination 'vm.config.example.psd1') + NextSteps = @( + "Copy vm.config.example.psd1 to vm.config.psd1 and set the VM name, paths, and architecture.", + "Obtain media: pwsh $(Join-Path $PSScriptRoot 'Get-WindowsMedia.ps1') -Source Fido -Windows 11 -Architecture x64 -DestinationRoot $(Join-Path $destination 'media')", + "HUMAN-ONLY, elevated, once: pwsh $(Join-Path $PSScriptRoot 'Initialize-LocalVmHost.ps1') -VmRoot $destination -InstallMedia ", + "It joins Hyper-V Administrators, saves the DPAPI guest credential, and creates the guest - an agent cannot do any of these.", + "Agents: verify with -CheckOnly and stop until it reports IsReady=true." + ) +} | ConvertTo-Json -Depth 4 diff --git a/.github/skills/ui-tests-local-vm/scripts/Initialize-LocalVmHost.ps1 b/.github/skills/ui-tests-local-vm/scripts/Initialize-LocalVmHost.ps1 new file mode 100644 index 000000000000..9d44b436a0ab --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Initialize-LocalVmHost.ps1 @@ -0,0 +1,418 @@ +# 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. + +<# +.SYNOPSIS +Completes the local UI-test VM host setup - the steps an agent cannot perform itself. + +.DESCRIPTION +Three prerequisites gate every agent-driven run, and all three need a human: + + 1. Hyper-V access Membership in the local Hyper-V Administrators group. Needs elevation, and + only takes effect after signing out and back in. + 2. Guest credential A DPAPI-protected PSCredential for the guest administrator. The password is + typed straight into the prompt so it never reaches a command line, a + configuration file, source control, or a model. + 3. The guest itself New-UiTestVm.ps1 reads the installation media and creates the virtual disk, + which needs an elevated shell. + +Run this once per host from an elevated PowerShell 7 terminal. It reports what is already in place, +performs only what is missing, and is safe to re-run. + +Agents: run it with -CheckOnly (no elevation, changes nothing) and, when it reports NotReady, stop +and ask the user to run the command it prints. Do not attempt to work around it. + +.EXAMPLE +# Human, elevated - the whole setup in one command. +pwsh ./Initialize-LocalVmHost.ps1 -VmRoot C:\PowerToysUiTestVm -InstallMedia C:\media\Win11.iso + +.EXAMPLE +# Agent - observe only. +pwsh ./Initialize-LocalVmHost.ps1 -VmRoot C:\PowerToysUiTestVm -CheckOnly +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [string]$VmRoot, + + [string]$ConfigPath, + [string]$InstallMedia, + [string]$ImageName = 'Windows 11 Pro', + + # Account that will run the tests. Defaults to whoever runs this script. + [string]$Account = "$env:USERDOMAIN\$env:USERNAME", + [string]$AdminUserName, + [string]$CredentialPath = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml'), + [string]$VcRedistUrl, + [string]$PowerShellVersion = '7.6.4', + [string]$PowerShellUrl, + [string]$PowerShellSha256, + + [switch]$CheckOnly, + [switch]$SkipScaffoldRefresh, + [switch]$SkipVcRedist, + [switch]$SkipPowerShell, + [switch]$SkipWindowsUpdate, + [switch]$SkipGroupMembership, + [switch]$SkipCredential, + [switch]$SkipGuestCreation, + [switch]$AllowReFsVolume, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this script with PowerShell 7 (pwsh).' +} + +Import-Module (Join-Path $PSScriptRoot 'LocalVmGuest.psm1') -Force + +$vmRootPath = [IO.Path]::GetFullPath($VmRoot) +if (-not (Test-Path $vmRootPath -PathType Container)) { + throw "VM root was not found: $vmRootPath. Run Initialize-LocalVm.ps1 -DestinationRoot $vmRootPath first." +} + +# Existing scaffolds are copies, so they do not receive skill fixes automatically. Refresh before +# every mutating setup run; vm.config.psd1, media, VHDX files, and extra OEM installers are preserved. +if (-not $CheckOnly -and -not $SkipScaffoldRefresh) { + Write-Host 'Refreshing the VM scaffold from the current skill templates...' + & (Join-Path $PSScriptRoot 'Initialize-LocalVm.ps1') -DestinationRoot $vmRootPath -Force | Out-Null +} + +if ([string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $vmRootPath 'vm.config.psd1' +} +if (-not (Test-Path $ConfigPath -PathType Leaf)) { + throw "Configuration was not found: $ConfigPath. Copy vm.config.example.psd1 to vm.config.psd1 and edit it." +} + +$configuration = Import-PowerShellDataFile $ConfigPath +$vmName = [string]$configuration.VmName +if ([string]::IsNullOrWhiteSpace($AdminUserName)) { + $AdminUserName = [string]$configuration.AdminUserName +} + +$vcArchitecture = switch ([string]$configuration.ProcessorArchitecture) { + 'arm64' { 'arm64' } + default { 'x64' } +} +$vcRedistPath = Join-Path $vmRootPath "oem\vc_redist.$vcArchitecture.exe" +$vcRedistReady = if (Test-Path $vcRedistPath -PathType Leaf) { + $existingVcSignature = Get-AuthenticodeSignature $vcRedistPath + $existingVcSignature.Status -eq 'Valid' -and + $null -ne $existingVcSignature.SignerCertificate -and + $existingVcSignature.SignerCertificate.Subject -like 'CN=Microsoft Corporation*' +} +else { + $false +} +$powerShellPath = Join-Path $vmRootPath "oem\PowerShell-$PowerShellVersion-win-$vcArchitecture.msi" +$knownPowerShellHashes = @{ + 'x64' = 'D11942DF52FD12470169797ABFA4781D9480EFDC81000BA4FA55A5B921ED8DD0' + 'arm64' = '9B441D52176BEFD22B3AADF34F2F43F3A6F692C8D0181815169A397236B33D1F' +} +$expectedPowerShellHash = if ([string]::IsNullOrWhiteSpace($PowerShellSha256)) { + if ($PowerShellVersion -ne '7.6.4') { + throw '-PowerShellSha256 is required when overriding the pinned PowerShell version 7.6.4.' + } + $knownPowerShellHashes[$vcArchitecture] +} +else { + $PowerShellSha256.ToUpperInvariant() +} +$powerShellPayloadValid = if (Test-Path $powerShellPath -PathType Leaf) { + $existingHash = (Get-FileHash $powerShellPath -Algorithm SHA256).Hash + $existingSignature = Get-AuthenticodeSignature $powerShellPath + $existingSignerIsMicrosoft = $null -ne $existingSignature.SignerCertificate -and + $existingSignature.SignerCertificate.Subject -like 'CN=Microsoft Corporation*' + $existingHash -eq $expectedPowerShellHash -and + $existingSignature.Status -eq 'Valid' -and + $existingSignerIsMicrosoft +} +else { + $false +} +$powerShellMetadataPath = "$powerShellPath.sha256" +$powerShellMetadataValid = (Test-Path $powerShellMetadataPath -PathType Leaf) -and + ((Get-Content $powerShellMetadataPath -Raw).Trim() -eq $expectedPowerShellHash) +$powerShellReady = $powerShellPayloadValid -and $powerShellMetadataValid + +function Install-PowerShellPayload { + if ($powerShellReady) { + return + } + if ($SkipPowerShell) { + return + } + if ($powerShellPayloadValid) { + Set-Content $powerShellMetadataPath $expectedPowerShellHash -Encoding ascii + $script:powerShellMetadataValid = $true + $script:powerShellReady = $true + Write-Host "Repaired PowerShell payload trust metadata: $powerShellMetadataPath" + return + } + + $url = if ([string]::IsNullOrWhiteSpace($PowerShellUrl)) { + "https://github.com/PowerShell/PowerShell/releases/download/v$PowerShellVersion/PowerShell-$PowerShellVersion-win-$vcArchitecture.msi" + } + else { + $PowerShellUrl + } + + New-Item (Split-Path $powerShellPath -Parent) -ItemType Directory -Force | Out-Null + $temporaryPath = "$powerShellPath.download" + Write-Host "Downloading PowerShell $PowerShellVersion for guest-side test orchestration..." + Invoke-WebRequest -Uri $url -OutFile $temporaryPath + + $actualHash = (Get-FileHash $temporaryPath -Algorithm SHA256).Hash + $signature = Get-AuthenticodeSignature $temporaryPath + $isMicrosoft = $null -ne $signature.SignerCertificate -and + $signature.SignerCertificate.Subject -like 'CN=Microsoft Corporation*' + if ($actualHash -ne $expectedPowerShellHash -or $signature.Status -ne 'Valid' -or -not $isMicrosoft) { + Remove-Item $temporaryPath -Force -ErrorAction SilentlyContinue + throw "Refusing PowerShell payload '$url': sha256=$actualHash, expected=$expectedPowerShellHash, signature=$($signature.Status), signer='$($signature.SignerCertificate.Subject)'." + } + + Move-Item $temporaryPath $powerShellPath -Force + Set-Content $powerShellMetadataPath $expectedPowerShellHash -Encoding ascii + $script:powerShellPayloadValid = $true + $script:powerShellMetadataValid = $true + $script:powerShellReady = $true + Write-Host "Staged verified PowerShell ${PowerShellVersion}: $powerShellPath" +} + +function Install-VcRedistPayload { + if ($vcRedistReady -or $SkipVcRedist) { + return + } + + $url = if ([string]::IsNullOrWhiteSpace($VcRedistUrl)) { + "https://aka.ms/vs/17/release/vc_redist.$vcArchitecture.exe" + } + else { + $VcRedistUrl + } + + New-Item (Split-Path $vcRedistPath -Parent) -ItemType Directory -Force | Out-Null + $temporaryPath = "$vcRedistPath.download" + Write-Host "Downloading the Visual C++ redistributable required for MP4 capture..." + Invoke-WebRequest -Uri $url -OutFile $temporaryPath + + $signature = Get-AuthenticodeSignature $temporaryPath + $isMicrosoft = $null -ne $signature.SignerCertificate -and + $signature.SignerCertificate.Subject -like 'CN=Microsoft Corporation*' + if ($signature.Status -ne 'Valid' -or -not $isMicrosoft) { + Remove-Item $temporaryPath -Force -ErrorAction SilentlyContinue + throw "Refusing VC++ redistributable from '$url': signature=$($signature.Status), signer='$($signature.SignerCertificate.Subject)'." + } + + Move-Item $temporaryPath $vcRedistPath -Force + $script:vcRedistReady = $true + Write-Host "Staged the signed Microsoft redistributable: $vcRedistPath" +} + +function Test-Elevation { + return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Write-Status { + param([Parameter(Mandatory)]$Status) + + Write-Host '' + Write-Host "Local UI-test VM host setup - $vmName" + foreach ($row in @( + @{ Name = 'Hyper-V access'; Ok = $Status.HyperVAccess; Detail = $Status.HyperVAccessDetail }, + @{ Name = 'Guest credential'; Ok = $Status.Credential; Detail = $Status.CredentialDetail }, + @{ Name = 'Video prerequisite'; Ok = $vcRedistReady; Detail = $(if ($vcRedistReady) { "ok ($vcArchitecture)" } else { "missing: $vcRedistPath" }) }, + @{ Name = 'PowerShell 7'; Ok = $powerShellReady; Detail = $(if ($powerShellReady) { "ok ($PowerShellVersion)" } else { "missing: $powerShellPath" }) }, + @{ Name = 'Guest'; Ok = $Status.Guest; Detail = $Status.GuestDetail })) { + $mark = if ($row.Ok) { '[ok] ' } else { '[missing]' } + Write-Host (" {0} {1,-17} {2}" -f $mark, $row.Name, $row.Detail) + } + Write-Host '' +} + +$status = Test-LocalVmHostSetup -VmName $vmName -CredentialPath $CredentialPath -AdminUserName $AdminUserName +Write-Status -Status $status + +if ($CheckOnly) { + $allMissing = @($status.Missing) + if (-not $vcRedistReady) { + $allMissing += 'VideoPrerequisite' + } + if (-not $powerShellReady) { + $allMissing += 'PowerShell7' + } + $allReady = $status.IsReady -and $vcRedistReady -and $powerShellReady + + if (-not $allReady) { + $media = if ([string]::IsNullOrWhiteSpace($InstallMedia)) { '' } else { $InstallMedia } + Write-Host (Get-LocalVmSetupMessage ` + -Status $status ` + -VmRoot $vmRootPath ` + -InstallMedia $media ` + -ConfigPath $ConfigPath ` + -CredentialPath $CredentialPath) + } + [pscustomobject]@{ + VmName = $vmName + IsReady = $allReady + Missing = $allMissing + CredentialPath = $status.CredentialPath + VcRedistReady = $vcRedistReady + PowerShellReady = $powerShellReady + } | ConvertTo-Json -Depth 3 + exit ($(if ($allReady) { 0 } else { 1 })) +} + +$elevated = Test-Elevation +$needsElevation = (-not $status.HyperVAccess -and -not $SkipGroupMembership) -or + ((-not $status.Guest -or $Force) -and -not $SkipGuestCreation) +if ($needsElevation -and -not $elevated) { + throw @" +BLOCKED: this run needs an elevated PowerShell 7 terminal. +Missing: $($status.Missing -join ', ') + +Start an elevated pwsh and re-run: + pwsh -File "$PSCommandPath" -VmRoot "$vmRootPath" -ConfigPath "$ConfigPath" -CredentialPath "$CredentialPath"$(if ($InstallMedia) { " -InstallMedia `"$InstallMedia`"" }) +"@ +} + +# 1. Hyper-V access ------------------------------------------------------------------------------- +if (-not $status.HyperVAccess -and -not $SkipGroupMembership) { + $member = $Account + Write-Host "Adding '$member' to the local Hyper-V Administrators group..." + if ($PSCmdlet.ShouldProcess($member, 'Add to Hyper-V Administrators')) { + try { + Add-LocalGroupMember -Group 'Hyper-V Administrators' -Member $member -ErrorAction Stop + Write-Host "Added '$member'." + } + catch [Microsoft.PowerShell.Commands.MemberExistsException] { + Write-Host "'$member' is already a member." + } + } + + Write-Warning @' +Group membership is baked into the logon token, so this session cannot use it yet. +SIGN OUT AND BACK IN, then re-run this script to finish the remaining steps. +'@ + return +} + +# 2. Guest credential ----------------------------------------------------------------------------- +if (-not $status.Credential -and -not $SkipCredential) { + Write-Host "Saving the guest administrator credential for '$AdminUserName'." + Write-Host 'Type the password directly into the prompt. Any password works: it only ever exists' + Write-Host 'inside the disposable guest. Never reuse a real account password here.' + + if ($PSCmdlet.ShouldProcess($CredentialPath, 'Save the DPAPI guest credential')) { + New-Item (Split-Path $CredentialPath -Parent) -ItemType Directory -Force | Out-Null + $credential = Get-Credential -UserName $AdminUserName -Message "Local UI-test VM administrator ($vmName)" + if ($null -eq $credential) { + throw 'No credential was entered.' + } + if (($credential.UserName -replace '^.*\\', '') -ne $AdminUserName) { + throw "The credential must be for '$AdminUserName' to match $ConfigPath." + } + $credential | Export-Clixml $CredentialPath + Write-Host "Saved: $CredentialPath (decryptable only by $env:USERNAME on this host)." + } +} + +# 3. Recording prerequisite ----------------------------------------------------------------------- +Install-VcRedistPayload +Install-PowerShellPayload + +# 4. The guest ------------------------------------------------------------------------------------ +if ((-not $status.Guest -or $Force) -and -not $SkipGuestCreation) { + if ([string]::IsNullOrWhiteSpace($InstallMedia)) { + throw @" +BLOCKED: -InstallMedia is required to create '$vmName'. +Obtain media first, for example: + pwsh "$(Join-Path $PSScriptRoot 'Get-WindowsMedia.ps1')" -Source Fido -Windows 11 -Architecture x64 -DestinationRoot "$(Join-Path $vmRootPath 'media')" +"@ + } + + # Execute the source template, not the scaffold copy. A copied script can be stale after the + # skill is updated (the PowerShell 5.1 readiness-query fix exposed exactly this failure mode). + $newVmScript = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\templates\vm\New-UiTestVm.ps1')) + if (-not (Test-Path $newVmScript -PathType Leaf)) { + throw "New-UiTestVm.ps1 template was not found: $newVmScript" + } + + Write-Host "Creating '$vmName' from $InstallMedia. Windows Setup runs inside the guest; this takes a while and needs no interaction." + $arguments = @{ + ConfigPath = $ConfigPath + InstallMedia = $InstallMedia + ImageName = $ImageName + CredentialPath = $CredentialPath + OemPath = (Join-Path $vmRootPath 'oem') + } + if ($AllowReFsVolume) { $arguments.AllowReFsVolume = $true } + if ($Force) { $arguments.Force = $true } + & $newVmScript @arguments +} + +# Windows Setup Dynamic Update is the primary Win10 servicing path. Verify its result against the +# .NET 10 CET floor (1904x.5007); only then use online Windows Update as a fallback and replace the +# pre-update checkpoint. Windows 11 media does not need this compatibility step. +if (-not $SkipWindowsUpdate -and (Get-VM -Name $vmName -ErrorAction SilentlyContinue)) { + & ([IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\templates\vm\Start-LocalVm.ps1'))) ` + -ConfigPath $ConfigPath ` + -CredentialPath $CredentialPath ` + -Wait | Out-Null + $credential = Import-Clixml $CredentialPath + $session = New-PSSession -VMName $vmName -Credential $credential + try { + $guestVersion = Invoke-Command -Session $session -ScriptBlock { + $currentVersion = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + [pscustomobject]@{ + Build = [int]$currentVersion.CurrentBuild + Ubr = [int]$currentVersion.UBR + Display = "$($currentVersion.CurrentBuild).$($currentVersion.UBR)" + } + } + } + finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + } + + if ($guestVersion.Build -lt 22000 -and + ($guestVersion.Build -lt 19041 -or $guestVersion.Build -gt 19045 -or $guestVersion.Ubr -lt 5007)) { + Write-Warning "Windows Setup Dynamic Update left '$vmName' at $($guestVersion.Display); .NET 10 needs 1904x.5007 or newer. Falling back to online Windows Update." + & (Join-Path $PSScriptRoot 'Update-LocalVmGuest.ps1') ` + -VmName $vmName ` + -CredentialPath $CredentialPath + + & ([IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\templates\vm\Reset-LocalVm.ps1'))) ` + -ConfigPath $ConfigPath ` + -CreateBaseline | Out-Null + Write-Host "Recreated '$($configuration.BaselineCheckpointName)' after Windows Update." + } +} + +$final = Test-LocalVmHostSetup -VmName $vmName -CredentialPath $CredentialPath -AdminUserName $AdminUserName +Write-Status -Status $final +$finalMissing = @($final.Missing) +if (-not $vcRedistReady) { $finalMissing += 'VideoPrerequisite' } +if (-not $powerShellReady) { $finalMissing += 'PowerShell7' } +$allReady = $final.IsReady -and $vcRedistReady -and $powerShellReady +if ($allReady) { + Write-Host 'Host setup is complete. The agent can now drive Invoke-LocalVmUiTest.ps1 unattended.' +} +else { + Write-Warning "Still missing: $($finalMissing -join ', '). Re-run this script." +} + +[pscustomobject]@{ + VmName = $vmName + IsReady = $allReady + Missing = $finalMissing + CredentialPath = $final.CredentialPath + VcRedistReady = $vcRedistReady + PowerShellReady = $powerShellReady +} | ConvertTo-Json -Depth 3 diff --git a/.github/skills/ui-tests-local-vm/scripts/Invoke-GuestScript.ps1 b/.github/skills/ui-tests-local-vm/scripts/Invoke-GuestScript.ps1 new file mode 100644 index 000000000000..978f79fa8cff --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Invoke-GuestScript.ps1 @@ -0,0 +1,64 @@ +# 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. + +<# +.SYNOPSIS +Runs a scriptblock inside the local UI-test guest over PowerShell Direct, handling the credential +import and PSSession lifecycle. Token-efficient replacement for the repeated +Import-Clixml / New-PSSession / Invoke-Command / Remove-PSSession boilerplate when inspecting or +mutating guest state (package registration, staged runtime files, registry, processes). + +.PARAMETER ScriptBlock +The scriptblock to run in the guest. Its output is returned to the host. + +.PARAMETER VmName +Name of the Hyper-V virtual machine. Hyper-V access is required: either an elevated shell or +membership in the local Hyper-V Administrators group. + +.EXAMPLE +./Invoke-GuestScript.ps1 -VmName PowerToysUiTest-Win11 -ScriptBlock { + Get-AppxPackage *ImageResizerContextMenu* | Select-Object -Expand Name +} + +.EXAMPLE +./Invoke-GuestScript.ps1 -VmName PowerToysUiTest-Win11 -ScriptBlock { + Get-Process explorer | Select-Object Id, SessionId +} + +.EXAMPLE +# Neutralize a sparse package to reproduce CI's unsigned/classic scenario. +./Invoke-GuestScript.ps1 -VmName PowerToysUiTest-Win11 -ScriptBlock { + Get-AppxPackage -AllUsers *ImageResizerContextMenu* | ForEach-Object { Remove-AppxPackage -Package $_.PackageFullName -AllUsers } + Rename-Item C:\PowerToysUiTestRun\PowerToys\WinUI3Apps\ImageResizerContextMenuPackage.msix -NewName ImageResizerContextMenuPackage.msix.disabled +} +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][scriptblock]$ScriptBlock, + [Parameter(Mandatory)][string]$VmName, + [object[]]$ArgumentList = @(), + [string]$CredentialPath = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml') +) + +$ErrorActionPreference = 'Stop' +if (-not (Test-Path $CredentialPath)) { + throw "Credential file not found: $CredentialPath. Point -CredentialPath at the VM's admin.credential.xml." +} +$credential = Import-Clixml $CredentialPath + +try { + Import-Module Hyper-V -ErrorAction Stop + Get-VM -ErrorAction Stop | Out-Null +} +catch { + throw 'BLOCKED: Hyper-V is not accessible from this shell. Run from an elevated PowerShell 7 terminal, or add this account to the local "Hyper-V Administrators" group.' +} +$session = New-PSSession -VMName $VmName -Credential $credential + +try { + Invoke-Command -Session $session -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList +} +finally { + Remove-PSSession $session -ErrorAction SilentlyContinue +} diff --git a/.github/skills/ui-tests-local-vm/scripts/Invoke-LocalVmUiTest.ps1 b/.github/skills/ui-tests-local-vm/scripts/Invoke-LocalVmUiTest.ps1 new file mode 100644 index 000000000000..6f5254f7042b --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Invoke-LocalVmUiTest.ps1 @@ -0,0 +1,695 @@ +# 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. + +<# +.SYNOPSIS +Runs PowerToys UITest.Next executables in a persistent local Hyper-V VM. + +.DESCRIPTION +Creates a hash-addressed request, starts or reuses the guest, verifies a non-admin interactive +desktop, dispatches the shared guest runner through Task Scheduler, and returns durable status/TRX +evidence. + +The control channel is PowerShell Direct over VMBus and payloads move with Copy-VMFile, so the guest +needs no listener, no published port, and no network. Hyper-V access is required: either an elevated +shell or membership in the local Hyper-V Administrators group. + +.EXAMPLE +pwsh ./Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win10 ` + -VmRoot X:\PowerToysUiTestVm ` + -ExchangeRoot X:\PowerToysUiTestVm\shared\PowerToysUiTests\Peek ` + -TestExecutable Peek.UITests.Next.exe ` + -Filter 'Name=Peek.Preview.PDF' ` + -Platform x64Win10 ` + -BuildLabel (git rev-parse HEAD) ` + -ReuseStagedPayload +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string]$VmRoot, + + [Parameter(Mandatory)] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string]$ExchangeRoot, + + [Parameter(Mandatory)] + [string[]]$TestExecutable, + + [Parameter(Mandatory)] + [string]$VmName, + + [string]$ConfigurationPath, + [ValidateSet('Default', 'Constrained')] + [string]$ResourceProfile = 'Default', + [string]$Filter, + # Flows to the guest as the 'platform' environment variable. The framework uses it for visual + # baseline filenames (VisualAssert) and treats any non-empty value as "running in a pipeline", + # so it must match the names CI uses or baselines silently fail to resolve. + [ValidateSet('x64Win10', 'x64Win11', 'ARM64')] + [string]$Platform = 'x64Win10', + [string]$BuildLabel = 'local', + [string]$TestsArchive = 'ui-tests.zip', + [string]$ProductArchive = 'powertoys-runtime.zip', + [string]$WinAppCliArchive = 'winappcli.zip', + [string]$DotNetArchive = 'dotnet-runtime.zip', + [string]$ProductOverlayArchive, + [string]$WebView2Installer = 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe', + [string]$SuiteTimeout = '45m', + [string[]]$CleanupProcess = @(), + [ValidateRange(0, 300)] + [int]$OutputHeartbeatSeconds = 15, + [ValidateRange(0, 7680)] + [int]$DesktopWidth = 1920, + [ValidateRange(0, 4320)] + [int]$DesktopHeight = 1080, + [ValidateRange(1, 1440)] + [int]$TimeoutMinutes = 60, + [ValidateRange(1, 120)] + [int]$StartupTimeoutMinutes = 45, + [string]$StandardUser = 'PTUser', + [string]$GuestExchangeRoot = 'C:\PowerToysUiTestExchange', + [string]$CredentialPath, + [string]$GuestRunnerSource = (Join-Path $PSScriptRoot '..\templates\run-ui-tests.ps1'), + [switch]$InstallWebView2, + [switch]$ReuseStagedPayload, + [switch]$SkipStart, + [switch]$StopVmAfterRun, + [switch]$PlanOnly +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this controller with PowerShell 7 (pwsh).' +} +if (($DesktopWidth -eq 0) -ne ($DesktopHeight -eq 0)) { + throw 'Set both DesktopWidth and DesktopHeight to 0 to disable display validation.' +} +$CleanupProcess = @($CleanupProcess | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +Import-Module (Join-Path $PSScriptRoot 'LocalVmGuest.psm1') -Force + +$controllerName = 'Hyper-V local VM' +$vmRootPath = [IO.Path]::GetFullPath($VmRoot) +if ([string]::IsNullOrWhiteSpace($ConfigurationPath)) { + $ConfigurationPath = Join-Path $vmRootPath 'vm.config.psd1' +} +$configurationPathResolved = [IO.Path]::GetFullPath($ConfigurationPath) +if (-not (Test-Path $configurationPathResolved -PathType Leaf)) { + throw "VM configuration was not found: $configurationPathResolved" +} +$vmConfiguration = Import-PowerShellDataFile $configurationPathResolved +if ([string]$vmConfiguration.VmName -ne $VmName) { + throw "VM configuration '$configurationPathResolved' names '$($vmConfiguration.VmName)', but -VmName is '$VmName'." +} + +if ([string]::IsNullOrWhiteSpace($CredentialPath)) { + $CredentialPath = Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml' +} + +# Hyper-V access, the guest credential, and the guest itself all need a human. Fail on the whole set +# at once so the user gets one actionable instruction instead of three sequential surprises. +if (-not $PlanOnly) { + $guestAdminUser = [string]$vmConfiguration.AdminUserName + + $hostSetup = Test-LocalVmHostSetup -VmName $VmName -CredentialPath $CredentialPath -AdminUserName $guestAdminUser + if (-not $hostSetup.IsReady) { + throw (Get-LocalVmSetupMessage ` + -Status $hostSetup ` + -VmRoot $VmRoot ` + -ConfigPath $configurationPathResolved ` + -CredentialPath $CredentialPath) + } +} + +$exchangePath = [IO.Path]::GetFullPath($ExchangeRoot) +$guestRunnerSourcePath = [IO.Path]::GetFullPath($GuestRunnerSource) +if (-not (Test-Path $guestRunnerSourcePath -PathType Leaf)) { + throw "Guest runner was not found: $guestRunnerSourcePath" +} + +$guestContext = New-LocalVmContext ` + -VmName $VmName -HostExchangeRoot $exchangePath -GuestExchangeRoot $GuestExchangeRoot +$guestExchangeRoot = $guestContext.GuestExchangeRoot + +function Get-ExchangeFileHash { + param([string]$File) + + if ([string]::IsNullOrWhiteSpace($File)) { + return $null + } + return (Get-FileHash (Join-Path $exchangePath $File) -Algorithm SHA256).Hash +} + +function Get-TrxSummary { + param([Parameter(Mandatory)][string]$ResultRoot) + + $suites = @() + $totals = [ordered]@{ Total = 0; Executed = 0; Passed = 0; Failed = 0; Error = 0; NotExecuted = 0 } + foreach ($trx in Get-ChildItem $ResultRoot -Filter '*.trx' -File -Recurse -ErrorAction SilentlyContinue) { + [xml]$document = Get-Content $trx.FullName -Raw + $counters = $document.TestRun.ResultSummary.Counters + $tests = @($document.TestRun.Results.UnitTestResult | ForEach-Object { + [pscustomobject]@{ + Name = [string]$_.testName + Outcome = [string]$_.outcome + Duration = [string]$_.duration + ErrorMessage = [string]$_.Output.ErrorInfo.Message + } + }) + $suite = [ordered]@{ + File = $trx.FullName + Total = [int]$counters.total + Executed = [int]$counters.executed + Passed = [int]$counters.passed + Failed = [int]$counters.failed + Error = [int]$counters.error + NotExecuted = [int]$counters.notExecuted + Tests = $tests + } + $suites += [pscustomobject]$suite + foreach ($name in @('Total', 'Executed', 'Passed', 'Failed', 'Error', 'NotExecuted')) { + $totals[$name] += $suite[$name] + } + } + return [pscustomobject]@{ Totals = [pscustomobject]$totals; Suites = $suites } +} + +function Start-InteractiveTask { + param( + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string]$TaskName, + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$Arguments, + [Parameter(Mandatory)][string]$UserName, + [Parameter(Mandatory)][int]$ExecutionTimeLimitMinutes + ) + + return Invoke-Command -Session $Session -ScriptBlock { + param($Name, $FilePath, $ArgumentList, $InteractiveUser, $LimitMinutes) + + $ErrorActionPreference = 'Stop' + Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue + $action = New-ScheduledTaskAction -Execute $FilePath -Argument $ArgumentList + $principal = New-ScheduledTaskPrincipal ` + -UserId "$env:COMPUTERNAME\$InteractiveUser" ` + -LogonType Interactive -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit (New-TimeSpan -Minutes $LimitMinutes) ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $task = New-ScheduledTask -Action $action -Principal $principal -Settings $settings + Register-ScheduledTask -TaskName $Name -InputObject $task -Force | Out-Null + Start-ScheduledTask -TaskName $Name + $registered = Get-ScheduledTask -TaskName $Name + [pscustomobject]@{ + TaskName = $Name + State = [string]$registered.State + UserId = $registered.Principal.UserId + RunLevel = [string]$registered.Principal.RunLevel + } + } -ArgumentList $TaskName, $Executable, $Arguments, $UserName, $ExecutionTimeLimitMinutes +} + +$requiredFiles = @($TestsArchive, $ProductArchive, $WinAppCliArchive, $DotNetArchive) +if (-not [string]::IsNullOrWhiteSpace($ProductOverlayArchive)) { + $requiredFiles += $ProductOverlayArchive +} +if ($InstallWebView2) { + $requiredFiles += $WebView2Installer +} +foreach ($file in $requiredFiles) { + if (-not (Test-Path (Join-Path $exchangePath $file) -PathType Leaf)) { + throw "Required exchange file is missing: $file" + } +} + +$payloadFiles = @($requiredFiles | Sort-Object -Unique) +$payloadHashes = [ordered]@{ + Tests = Get-ExchangeFileHash -File $TestsArchive + Product = Get-ExchangeFileHash -File $ProductArchive + ProductOverlay = Get-ExchangeFileHash -File $ProductOverlayArchive + WinAppCli = Get-ExchangeFileHash -File $WinAppCliArchive + DotNet = Get-ExchangeFileHash -File $DotNetArchive + WebView2Installer = if ($InstallWebView2) { Get-ExchangeFileHash -File $WebView2Installer } else { $null } +} +$fingerprintLines = foreach ($file in $payloadFiles) { + '{0}={1}' -f $file, (Get-ExchangeFileHash -File $file) +} +$sha256 = [Security.Cryptography.SHA256]::Create() +try { + $payloadFingerprint = [Convert]::ToHexString( + $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($fingerprintLines -join "`n"))) +} +finally { + $sha256.Dispose() +} + +$runId = 'localvm-{0}-{1}' -f [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N').Substring(0, 8) +$resultRelative = "LocalVmResults\$runId" +$hostResultRoot = Join-Path $exchangePath $resultRelative +$guestResultRoot = Join-Path $guestExchangeRoot $resultRelative +$requestRelative = Join-Path $resultRelative 'request.json' +$statusRelative = Join-Path $resultRelative 'status.json' +$progressRelative = Join-Path $resultRelative 'progress.json' +$probeScriptRelative = Join-Path $resultRelative 'desktop-probe.ps1' +$probeRelative = Join-Path $resultRelative 'desktop-probe.json' +$requestPath = Join-Path $hostResultRoot 'request.json' +$guestRequestPath = Join-Path $guestResultRoot 'request.json' +$guestProbeScriptPath = Join-Path $guestResultRoot 'desktop-probe.ps1' +$guestProbePath = Join-Path $guestResultRoot 'desktop-probe.json' +$guestRunnerName = 'run-ui-tests.ps1' +$hostRunnerPath = Join-Path $exchangePath $guestRunnerName +$guestRunnerPath = Join-Path $guestExchangeRoot $guestRunnerName + +New-Item $hostResultRoot -ItemType Directory -Force | Out-Null +Copy-Item $guestRunnerSourcePath $hostRunnerPath -Force + +$request = [ordered]@{ + RunId = $runId + Controller = $controllerName + ExchangeRoot = $guestExchangeRoot + BuildLabel = $BuildLabel + TestExecutables = @($TestExecutable) + Filter = $Filter + Platform = $Platform + ResourceProfile = $ResourceProfile + SuiteTimeout = $SuiteTimeout + OutputHeartbeatSeconds = $OutputHeartbeatSeconds + DesktopWidth = $DesktopWidth + DesktopHeight = $DesktopHeight + ReuseStagedPayload = [bool]$ReuseStagedPayload + PayloadFingerprint = $payloadFingerprint + PayloadFiles = $payloadFiles + PayloadHashes = $payloadHashes + Archives = [ordered]@{ + Tests = $TestsArchive + Product = $ProductArchive + WinAppCli = $WinAppCliArchive + DotNet = $DotNetArchive + ProductOverlay = $ProductOverlayArchive + } + WebView2Installer = if ($InstallWebView2) { $WebView2Installer } else { $null } + CleanupProcesses = @($CleanupProcess) +} +$requestJson = $request | ConvertTo-Json -Depth 8 +$requestJson | Set-Content $requestPath -Encoding utf8 + +$plan = [ordered]@{ + Controller = $controllerName + RunId = $runId + VmRoot = $vmRootPath + VmName = $guestContext.VmName + ConfigurationPath = $configurationPathResolved + ResourceProfile = $ResourceProfile + ExchangeRoot = $exchangePath + GuestExchangeRoot = $guestExchangeRoot + GuestRunnerSource = $guestRunnerSourcePath + GuestRequestPath = $guestRequestPath + StandardUser = $StandardUser + ControlChannel = $guestContext.ControlChannel + ReuseStagedPayload = [bool]$ReuseStagedPayload + StopVmAfterRun = [bool]$StopVmAfterRun + PayloadFingerprint = $payloadFingerprint + PayloadHashes = $payloadHashes +} +$plan | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $hostResultRoot 'controller-plan.json') -Encoding utf8 +if ($PlanOnly) { + [pscustomobject]@{ + Status = 'PLAN' + RunId = $runId + ResultsPath = $hostResultRoot + RequestPath = $requestPath + PayloadFingerprint = $payloadFingerprint + } | ConvertTo-Json + return +} + +if (-not (Test-Path $CredentialPath -PathType Leaf)) { + throw "DPAPI credential file was not found: $CredentialPath" +} +$credential = Import-Clixml $CredentialPath +if ($credential -isnot [System.Management.Automation.PSCredential]) { + throw "Credential file does not contain a PSCredential: $CredentialPath" +} + +$session = $null +$evidenceExported = $false +$probeTaskName = "PowerToysUiTest-Probe-$runId" +$testTaskName = "PowerToysUiTest-Run-$runId" +$controllerResult = $null +try { + if (-not $SkipStart) { + $startScript = Join-Path $vmRootPath 'Start-LocalVm.ps1' + if (-not (Test-Path $startScript -PathType Leaf)) { + throw "VM start script was not found: $startScript" + } + $startupOutput = & $startScript ` + -ConfigPath $configurationPathResolved ` + -CredentialPath $CredentialPath ` + -ResourceProfile $ResourceProfile ` + -Wait ` + -TimeoutMinutes $StartupTimeoutMinutes | Out-String + Write-Verbose $startupOutput + } + + $session = New-LocalVmSession ` + -Context $guestContext -Credential $credential -TimeoutMinutes $StartupTimeoutMinutes + + $guestWindowsVersion = Invoke-Command -Session $session -ScriptBlock { + $currentVersion = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + [pscustomobject]@{ + Build = [int]$currentVersion.CurrentBuild + Ubr = [int]$currentVersion.UBR + Display = "$($currentVersion.CurrentBuild).$($currentVersion.UBR)" + } + } + if ($guestWindowsVersion.Build -lt 22000 -and + ($guestWindowsVersion.Build -lt 19041 -or $guestWindowsVersion.Build -gt 19045 -or $guestWindowsVersion.Ubr -lt 5007)) { + throw "BLOCKED: '$VmName' is Windows $($guestWindowsVersion.Display). .NET 10 and PowerShell 7.6 require Windows 10 1904x.5007 or newer for CET. Recreate through Initialize-LocalVmHost.ps1 (Setup Dynamic Update) or run Update-LocalVmGuest.ps1." + } + + $vcArchitecture = if ([string]$vmConfiguration.ProcessorArchitecture -eq 'arm64') { 'arm64' } else { 'x64' } + $vcRuntimePresent = Invoke-Command -Session $session -ScriptBlock { + Test-Path "$env:WINDIR\System32\VCRUNTIME140.dll" -PathType Leaf + } + if (-not $vcRuntimePresent) { + $vcRedistPath = Join-Path $vmRootPath "oem\vc_redist.$vcArchitecture.exe" + if (-not (Test-Path $vcRedistPath -PathType Leaf)) { + throw "BLOCKED: '$VmName' cannot record failure video because VCRUNTIME140.dll is missing, and no repair payload exists at '$vcRedistPath'. Run Initialize-LocalVmHost.ps1 for this VM config." + } + + $vcSignature = Get-AuthenticodeSignature $vcRedistPath + if ($vcSignature.Status -ne 'Valid' -or $vcSignature.SignerCertificate.Subject -notlike 'CN=Microsoft Corporation*') { + throw "BLOCKED: refusing untrusted VC++ redistributable '$vcRedistPath' (status=$($vcSignature.Status))." + } + + Invoke-Command -Session $session -ScriptBlock { + New-Item C:\PowerToysUiTestTools -ItemType Directory -Force | Out-Null + } + $guestVcRedist = 'C:\PowerToysUiTestTools\vc_redist.exe' + Copy-Item $vcRedistPath -Destination $guestVcRedist -ToSession $session -Force + $vcExitCode = Invoke-Command -Session $session -ScriptBlock { + param($Installer) + (Start-Process $Installer -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru).ExitCode + } -ArgumentList $guestVcRedist + if ($vcExitCode -notin 0, 1638, 3010 -or -not (Invoke-Command -Session $session -ScriptBlock { Test-Path "$env:WINDIR\System32\VCRUNTIME140.dll" })) { + throw "BLOCKED: Visual C++ redistributable installation failed in '$VmName' (exit=$vcExitCode)." + } + Write-Host "Installed the Visual C++ runtime in '$VmName' for failure-video capture." + } + + $guestPowerShell = 'C:\Program Files\PowerShell\7\pwsh.exe' + $powerShellPresent = Invoke-Command -Session $session -ScriptBlock { + param($Path) + Test-Path $Path -PathType Leaf + } -ArgumentList $guestPowerShell + if (-not $powerShellPresent) { + $powerShellMsi = Get-ChildItem (Join-Path $vmRootPath 'oem') ` + -Filter "PowerShell-*-win-$vcArchitecture.msi" -File | + Where-Object { Test-Path "$($_.FullName).sha256" -PathType Leaf } | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -eq $powerShellMsi) { + throw "BLOCKED: PowerShell 7 is missing in '$VmName', and no verified repair MSI exists under '$(Join-Path $vmRootPath 'oem')'. Run Initialize-LocalVmHost.ps1 for this VM config." + } + $expectedPowerShellHash = (Get-Content "$($powerShellMsi.FullName).sha256" -Raw).Trim() + $actualPowerShellHash = (Get-FileHash $powerShellMsi.FullName -Algorithm SHA256).Hash + $powerShellSignature = Get-AuthenticodeSignature $powerShellMsi.FullName + if ($actualPowerShellHash -ne $expectedPowerShellHash -or + $powerShellSignature.Status -ne 'Valid' -or + $powerShellSignature.SignerCertificate.Subject -notlike 'CN=Microsoft Corporation*') { + throw "BLOCKED: refusing unverified PowerShell MSI '$($powerShellMsi.FullName)' (sha256=$actualPowerShellHash, expected=$expectedPowerShellHash, status=$($powerShellSignature.Status))." + } + + Invoke-Command -Session $session -ScriptBlock { + New-Item C:\PowerToysUiTestTools -ItemType Directory -Force | Out-Null + } + $guestPowerShellMsi = 'C:\PowerToysUiTestTools\PowerShell.msi' + Copy-Item $powerShellMsi.FullName -Destination $guestPowerShellMsi -ToSession $session -Force + $powerShellExitCode = Invoke-Command -Session $session -ScriptBlock { + param($Installer) + (Start-Process msiexec.exe -ArgumentList @( + '/i', $Installer, '/qn', '/norestart', + 'ADD_PATH=1', 'REGISTER_MANIFEST=1', + 'ENABLE_PSREMOTING=0', 'USE_MU=0', 'ENABLE_MU=0') -Wait -PassThru).ExitCode + } -ArgumentList $guestPowerShellMsi + if ($powerShellExitCode -notin 0, 1638, 3010 -or -not (Invoke-Command -Session $session -ScriptBlock { + param($Path) + Test-Path $Path -PathType Leaf + } -ArgumentList $guestPowerShell)) { + throw "BLOCKED: PowerShell 7 installation failed in '$VmName' (exit=$powerShellExitCode)." + } + Write-Host "Installed PowerShell 7 in '$VmName' for guest-side test orchestration." + } + + Initialize-GuestExchange -Context $guestContext -Session $session -StandardUser $StandardUser + $stagedFiles = @(Copy-ToGuest ` + -Context $guestContext -Session $session -FileName (@($guestRunnerName) + $payloadFiles)) + if ($stagedFiles.Count -gt 0) { + Write-Host "Staged into the guest: $($stagedFiles -join ', ')" + } + Write-GuestText ` + -Context $guestContext -Session $session -RelativePath $requestRelative -Value $requestJson + + $controlIdentity = Invoke-Command -Session $session -ScriptBlock { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $windowsApplicationId = '55c92734-d682-4d71-983e-d6ec3f16059f' + $windowsLicense = Get-CimInstance SoftwareLicensingProduct -ErrorAction SilentlyContinue | + Where-Object { + $_.ApplicationID -eq $windowsApplicationId -and + -not [string]::IsNullOrWhiteSpace($_.PartialProductKey) -and + $_.Name -like 'Windows*' + } | + Select-Object -First 1 + [pscustomobject]@{ + User = $identity.Name + IsAdministrator = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + WindowsLicenseDescription = [string]$windowsLicense.Description + WindowsLicenseStatus = [int]$windowsLicense.LicenseStatus + WindowsGracePeriodMinutes = [int]$windowsLicense.GracePeriodRemaining + } + } + if (-not $controlIdentity.IsAdministrator) { + throw "The control identity '$($controlIdentity.User)' is not an administrator." + } + if ($controlIdentity.WindowsLicenseDescription.Contains('TIMEBASED_EVAL', [StringComparison]::OrdinalIgnoreCase) -and + ([int]$controlIdentity.WindowsLicenseStatus -eq 5 -or [int]$controlIdentity.WindowsGracePeriodMinutes -le 0)) { + throw 'The Windows evaluation period has expired. The guest will shut down hourly; replace it with current evaluation media or a properly licensed baseline before running UI tests.' + } + + $escapedProbePath = $guestProbePath.Replace("'", "''") + $escapedExchangeRoot = $guestExchangeRoot.Replace("'", "''") + $probeScript = @" +`$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +`$principal = [Security.Principal.WindowsPrincipal]`$identity +`$sessionId = (Get-Process -Id `$PID).SessionId +Add-Type -AssemblyName System.Windows.Forms +[ordered]@{ + User = `$identity.Name + SessionId = `$sessionId + IsAdministrator = `$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + ExplorerCount = @(Get-Process explorer -ErrorAction SilentlyContinue | Where-Object SessionId -eq `$sessionId).Count + DesktopWidth = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width + DesktopHeight = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height + ExchangeAccessible = Test-Path '$escapedExchangeRoot' +} | ConvertTo-Json | Set-Content '$escapedProbePath' -Encoding utf8 +"@ + Write-GuestText ` + -Context $guestContext -Session $session -RelativePath $probeScriptRelative -Value $probeScript + $probeArguments = '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $guestProbeScriptPath + $probeTask = Start-InteractiveTask ` + -Session $session -TaskName $probeTaskName ` + -Executable $guestPowerShell -Arguments $probeArguments ` + -UserName $StandardUser -ExecutionTimeLimitMinutes 2 + Write-Host "Desktop probe task: $($probeTask.TaskName), user $($probeTask.UserId)" + + $probeDeadline = [DateTime]::UtcNow.AddMinutes(2) + do { + $desktopProbe = Read-GuestJson ` + -Context $guestContext -Session $session -RelativePath $probeRelative -Attempts 3 + if ($null -ne $desktopProbe) { + break + } + if ([DateTime]::UtcNow -ge $probeDeadline) { + $probeTaskInfo = Invoke-Command -Session $session -ScriptBlock { + param($Name) + $task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue + $info = Get-ScheduledTaskInfo -TaskName $Name -ErrorAction SilentlyContinue + [pscustomobject]@{ State = [string]$task.State; LastTaskResult = $info.LastTaskResult } + } -ArgumentList $probeTaskName + throw "Interactive desktop probe did not complete. Task state: $($probeTaskInfo.State); result: $($probeTaskInfo.LastTaskResult)." + } + Start-Sleep -Seconds 1 + } while ($true) + + if ($desktopProbe.IsAdministrator) { + throw "Interactive test user '$($desktopProbe.User)' is an administrator." + } + if (-not $desktopProbe.User.EndsWith("\$StandardUser", [StringComparison]::OrdinalIgnoreCase)) { + throw "Interactive task ran as '$($desktopProbe.User)', expected '$StandardUser'." + } + if ([int]$desktopProbe.SessionId -le 0 -or [int]$desktopProbe.ExplorerCount -le 0) { + throw "No interactive Explorer desktop is available for '$StandardUser'." + } + if (-not $desktopProbe.ExchangeAccessible) { + throw "The interactive user cannot access '$guestExchangeRoot'." + } + if ($DesktopWidth -ne 0 -and + ([int]$desktopProbe.DesktopWidth -ne $DesktopWidth -or [int]$desktopProbe.DesktopHeight -ne $DesktopHeight)) { + throw "Guest desktop is $($desktopProbe.DesktopWidth)x$($desktopProbe.DesktopHeight); expected ${DesktopWidth}x${DesktopHeight}." + } + + $runnerArguments = '-NoLogo -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -RequestPath "{1}"' -f $guestRunnerPath, $guestRequestPath + $taskLimitMinutes = [Math]::Min(1440, $TimeoutMinutes + 5) + $testTask = Start-InteractiveTask ` + -Session $session -TaskName $testTaskName ` + -Executable $guestPowerShell -Arguments $runnerArguments ` + -UserName $StandardUser -ExecutionTimeLimitMinutes $taskLimitMinutes + Write-Host "UI-test task: $($testTask.TaskName), user $($testTask.UserId)" + + $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) + $lastProgress = $null + $status = $null + do { + $progress = Read-GuestJson ` + -Context $guestContext -Session $session -RelativePath $progressRelative + if ($null -ne $progress) { + $progressKey = "$($progress.Stage):$($progress.Detail)" + if ($progressKey -ne $lastProgress) { + Write-Host "[$($progress.Stage)] $($progress.Detail)" + $lastProgress = $progressKey + } + } + $candidate = Read-GuestJson ` + -Context $guestContext -Session $session -RelativePath $statusRelative + if ($null -ne $candidate -and $candidate.RunId -eq $runId) { + $status = $candidate + break + } + if ([DateTime]::UtcNow -ge $deadline) { + # Absorb a status file that was still being created when the deadline elapsed. + $candidate = Read-GuestJson ` + -Context $guestContext -Session $session -RelativePath $statusRelative -Attempts 20 + if ($null -ne $candidate -and $candidate.RunId -eq $runId) { + $status = $candidate + break + } + $taskInfo = Invoke-Command -Session $session -ScriptBlock { + param($Name) + $task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue + $info = Get-ScheduledTaskInfo -TaskName $Name -ErrorAction SilentlyContinue + [pscustomobject]@{ State = [string]$task.State; LastTaskResult = $info.LastTaskResult } + } -ArgumentList $testTaskName + throw "Local VM UI-test run timed out. Task state: $($taskInfo.State); result: $($taskInfo.LastTaskResult)." + } + Start-Sleep -Seconds 1 + } while ($true) + + Copy-FromGuest -Context $guestContext -Session $session -RelativePath $resultRelative + $evidenceExported = $true + + $trx = Get-TrxSummary -ResultRoot $hostResultRoot + $nonPassingTests = @($trx.Suites | ForEach-Object { $_.Tests } | Where-Object { $_.Outcome -ne 'Passed' }) + $effectiveExitCode = [int]$status.ExitCode + $effectiveStatus = [string]$status.Status + if ($trx.Suites.Count -eq 0 -or + $trx.Totals.Total -eq 0 -or + $trx.Totals.Executed -ne $trx.Totals.Total -or + $nonPassingTests.Count -gt 0) { + $effectiveStatus = 'FAIL' + if ($effectiveExitCode -eq 0) { + $effectiveExitCode = 1 + } + } + $controllerResult = [pscustomobject]@{ + Controller = $controllerName + RunId = $runId + BuildLabel = $status.BuildLabel + Status = $effectiveStatus + ExitCode = $effectiveExitCode + ResultsPath = $hostResultRoot + ControlUser = $controlIdentity.User + GuestUser = $status.User + GuestSessionId = $status.SessionId + DesktopWidth = $status.DesktopWidth + DesktopHeight = $status.DesktopHeight + ResourceProfile = $ResourceProfile + ReusedStagedPayload = $status.ReusedStagedPayload + RefreshedComponents = $status.RefreshedComponents + ExportErrors = $status.ExportErrors + Tests = $trx.Totals + Failed = @($nonPassingTests | ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + Outcome = $_.Outcome + Duration = $_.Duration + Error = (($_.ErrorMessage -split "`n") | Select-Object -First 1) + } + }) + Suites = $trx.Suites + } +} +finally { + if ($null -ne $session) { + if ($session.State -eq 'Opened') { + if (-not $evidenceExported) { + try { + Copy-FromGuest -Context $guestContext -Session $session -RelativePath $resultRelative + $evidenceExported = $true + } + catch { + Write-Warning "Guest evidence could not be exported: $($_.Exception.Message)" + } + } + if ($evidenceExported) { + try { + Remove-GuestItem -Context $guestContext -Session $session -RelativePath $resultRelative + } + catch { + Write-Warning "The guest run folder could not be removed: $($_.Exception.Message)" + } + } + } + try { + if ($session.State -eq 'Opened') { + Invoke-Command -Session $session -ScriptBlock { + param($ProbeTaskName, $TestTaskName) + foreach ($name in @($ProbeTaskName, $TestTaskName)) { + Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue + } + } -ArgumentList $probeTaskName, $testTaskName -ErrorAction Stop + } + } + catch { + Write-Warning "Scheduled-task cleanup was skipped because the guest session was unavailable: $($_.Exception.Message)" + } + finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + } + } + if ($StopVmAfterRun) { + $stopScript = Join-Path $vmRootPath 'Stop-LocalVm.ps1' + if (Test-Path $stopScript -PathType Leaf) { + try { + & $stopScript ` + -ConfigPath $configurationPathResolved ` + -CredentialPath $CredentialPath | Out-Host + } + catch { + Write-Warning "The requested post-run VM stop failed: $($_.Exception.Message)" + } + } + } +} + +if ($null -ne $controllerResult) { + $controllerResult | ConvertTo-Json -Depth 8 + if ($controllerResult.ExitCode -ne 0) { + exit $controllerResult.ExitCode + } +} diff --git a/.github/skills/ui-tests-local-vm/scripts/LocalVmGuest.psm1 b/.github/skills/ui-tests-local-vm/scripts/LocalVmGuest.psm1 new file mode 100644 index 000000000000..0db401708819 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/LocalVmGuest.psm1 @@ -0,0 +1,443 @@ +# 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. + +<# +.SYNOPSIS +Guest transport for the local Hyper-V UI-test VM. + +.DESCRIPTION +The control channel is PowerShell Direct over VMBus: no listener, no published port, no certificate, +and no network dependency. Bulk payloads move with Copy-VMFile over the Guest Service Interface, +measured at ~82 MB/s against ~17 MB/s for the PowerShell Direct session copy - and the session copy +stalls outright on archives approaching a gigabyte, so it is only a fallback. + +The exchange is guest-local storage that this module mirrors in both directions, so the host never +shares a folder with the guest. +#> + +Set-StrictMode -Version 3.0 + +$script:DefaultGuestExchangeRoot = 'C:\PowerToysUiTestExchange' + +function Test-HyperVAccess { + <# + .SYNOPSIS + Reports whether this shell can manage Hyper-V. + + .DESCRIPTION + Tests the capability rather than the token shape. Elevation is the usual way to get it, but + membership in the local Hyper-V Administrators group survives UAC filtering and is enough for + Get-VM, Copy-VMFile, and PowerShell Direct. + #> + try { + Import-Module Hyper-V -ErrorAction Stop + Get-VM -ErrorAction Stop | Out-Null + return $true + } + catch { + return $false + } +} + +function Get-HyperVAccessMessage { + return 'BLOCKED: Hyper-V is not accessible from this shell. Run from an elevated PowerShell 7 terminal, or add this account to the local "Hyper-V Administrators" group (a one-time elevated change that takes effect after signing out and back in).' +} + +function Test-LocalVmHostSetup { + <# + .SYNOPSIS + Reports which of the human-only host prerequisites are in place. + + .DESCRIPTION + Three things must exist before any agent-driven run can work, and none of them can be created by + an agent: Hyper-V access for this shell, the DPAPI guest-administrator credential, and the guest + itself. Initialize-LocalVmHost.ps1 performs all three; this function only observes them. + #> + [CmdletBinding()] + param( + [string]$VmName, + [string]$CredentialPath, + [string]$AdminUserName = 'PTAdmin' + ) + + if ([string]::IsNullOrWhiteSpace($CredentialPath)) { + $CredentialPath = Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml' + } + + $hyperVAccess = Test-HyperVAccess + + $credentialDetail = 'ok' + $credentialReady = $false + if (-not (Test-Path $CredentialPath -PathType Leaf)) { + $credentialDetail = "missing: $CredentialPath" + } + else { + try { + $credential = Import-Clixml $CredentialPath + if ($credential -isnot [pscredential]) { + $credentialDetail = 'file does not contain a PSCredential' + } + else { + $credentialUser = $credential.UserName -replace '^.*\\', '' + if ($credentialUser -ne $AdminUserName) { + $credentialDetail = "stored for '$credentialUser' but the configuration expects '$AdminUserName'" + } + else { + $credentialReady = $true + } + } + } + catch { + # A credential saved by another Windows user or on another host cannot be decrypted here. + $credentialDetail = "unreadable ($($_.Exception.Message))" + } + } + + $guestReady = $false + $guestDetail = 'not checked (no Hyper-V access)' + if ($hyperVAccess -and -not [string]::IsNullOrWhiteSpace($VmName)) { + $guest = Get-VM -Name $VmName -ErrorAction SilentlyContinue + if ($null -eq $guest) { + $guestDetail = "missing: no virtual machine named '$VmName'" + } + else { + $guestReady = $true + $guestDetail = "ok (State=$($guest.State))" + } + } + elseif ($hyperVAccess) { + $guestDetail = 'not checked (no VmName supplied)' + $guestReady = $true + } + + $missing = @() + if (-not $hyperVAccess) { $missing += 'HyperVAccess' } + if (-not $credentialReady) { $missing += 'Credential' } + if (-not $guestReady) { $missing += 'Guest' } + + return [pscustomobject]@{ + HyperVAccess = $hyperVAccess + HyperVAccessDetail = if ($hyperVAccess) { 'ok' } else { 'missing: elevate, or join the local "Hyper-V Administrators" group and sign out/in' } + Credential = $credentialReady + CredentialDetail = $credentialDetail + CredentialPath = $CredentialPath + Guest = $guestReady + GuestDetail = $guestDetail + VmName = $VmName + Missing = $missing + IsReady = $missing.Count -eq 0 + } +} + +function Get-LocalVmSetupMessage { + <# + .SYNOPSIS + Builds the BLOCKED message naming the one command a human must run. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Status, + [string]$VmRoot = '', + [string]$InstallMedia = '', + [string]$ConfigPath, + [string]$CredentialPath + ) + + $configArgument = if ([string]::IsNullOrWhiteSpace($ConfigPath)) { '' } else { " -ConfigPath `"$ConfigPath`"" } + $credentialArgument = if ([string]::IsNullOrWhiteSpace($CredentialPath)) { '' } else { " -CredentialPath `"$CredentialPath`"" } + $lines = @( + "BLOCKED: local-VM host setup is incomplete ($($Status.Missing -join ', ')).", + " Hyper-V access : $($Status.HyperVAccessDetail)", + " Credential : $($Status.CredentialDetail)", + " Guest : $($Status.GuestDetail)", + '', + 'These steps need a human: they require elevation (which no tool call can approve) and a', + 'password (which must never be routed through a model). Ask the user to run, once:', + '', + " pwsh -File \scripts\Initialize-LocalVmHost.ps1 -VmRoot `"$VmRoot`" -InstallMedia `"$InstallMedia`"$configArgument$credentialArgument", + '', + 'Do not continue or work around this - re-check with -CheckOnly after they confirm.') + return ($lines -join [Environment]::NewLine) +} + +function New-LocalVmContext { + <# + .SYNOPSIS + Describes how to reach the guest without connecting to it. + + .DESCRIPTION + The returned context is safe to serialize into a plan: it contains no credential material. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$VmName, + + [Parameter(Mandatory)] + [string]$HostExchangeRoot, + + [string]$GuestExchangeRoot = $script:DefaultGuestExchangeRoot + ) + + $hostExchangePath = [IO.Path]::GetFullPath($HostExchangeRoot) + if (-not (Test-Path $hostExchangePath -PathType Container)) { + throw "Host exchange root was not found: $hostExchangePath" + } + + $exchangeName = Split-Path $hostExchangePath -Leaf + return [pscustomobject]@{ + VmName = $VmName + HostExchangeRoot = $hostExchangePath + GuestExchangeRoot = (Join-Path $GuestExchangeRoot.TrimEnd('\') $exchangeName) + ControlChannel = "vmbus://$VmName" + } +} + +function New-LocalVmSession { + <# + .SYNOPSIS + Opens an administrative PowerShell Direct session to the guest, retrying until the deadline. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][pscredential]$Credential, + [ValidateRange(1, 240)][int]$TimeoutMinutes = 45 + ) + + if (-not (Test-HyperVAccess)) { + throw (Get-HyperVAccessMessage) + } + + $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) + do { + try { + return New-PSSession -VMName $Context.VmName -Credential $Credential -ErrorAction Stop + } + catch { + if ([DateTime]::UtcNow -ge $deadline) { + throw "Could not establish PowerShell Direct to '$($Context.VmName)'. $($_.Exception.Message)" + } + Start-Sleep -Seconds 5 + } + } while ($true) +} + +function Initialize-GuestExchange { + <# + .SYNOPSIS + Ensures the guest exchange directory exists and is writable by the interactive test user. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string]$StandardUser + ) + + Invoke-Command -Session $Session -ScriptBlock { + param($Path, $User) + + $ErrorActionPreference = 'Stop' + New-Item $Path -ItemType Directory -Force | Out-Null + $acl = Get-Acl $Path + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + "$env:COMPUTERNAME\$User", + [Security.AccessControl.FileSystemRights]::Modify, + [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) + $acl.SetAccessRule($rule) + Set-Acl $Path $acl + } -ArgumentList $Context.GuestExchangeRoot, $StandardUser +} + +function Copy-ToGuest { + <# + .SYNOPSIS + Copies exchange files into the guest, skipping files that already match by hash. + + .OUTPUTS + The names of the files that were actually transferred. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string[]]$FileName, + [switch]$Force + ) + + $guestHashes = @{} + if (-not $Force) { + $guestHashes = Invoke-Command -Session $Session -ScriptBlock { + param($Root, $Names) + + $result = @{} + foreach ($name in $Names) { + $path = Join-Path $Root $name + if (Test-Path $path -PathType Leaf) { + $result[$name] = (Get-FileHash $path -Algorithm SHA256).Hash + } + } + return $result + } -ArgumentList $Context.GuestExchangeRoot, $FileName + } + + $copied = @() + foreach ($name in $FileName) { + $source = Join-Path $Context.HostExchangeRoot $name + if (-not (Test-Path $source -PathType Leaf)) { + throw "Required exchange file is missing: $source" + } + if (-not $Force -and $guestHashes.ContainsKey($name) -and + $guestHashes[$name] -eq (Get-FileHash $source -Algorithm SHA256).Hash) { + continue + } + + $destination = Join-Path $Context.GuestExchangeRoot $name + $copied += $name + try { + Copy-VMFile -Name $Context.VmName -SourcePath $source -DestinationPath $destination ` + -CreateFullPath -FileSource Host -Force -ErrorAction Stop + } + catch { + Write-Verbose "Copy-VMFile failed for '$name', falling back to the session copy: $($_.Exception.Message)" + Invoke-Command -Session $Session -ScriptBlock { + param($Path) + New-Item (Split-Path $Path -Parent) -ItemType Directory -Force | Out-Null + } -ArgumentList $destination + Copy-Item $source -Destination $destination -ToSession $Session -Force + } + } + + return $copied +} + +function Copy-FromGuest { + <# + .SYNOPSIS + Merges a guest directory subtree into the matching host exchange location. + + .DESCRIPTION + Children are copied individually so that host-authored evidence already present in the + destination (request, plan, probe script) survives the transfer. Copy-VMFile is host-to-guest + only, so evidence returns over the session - it is small. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string]$RelativePath + ) + + $source = Join-Path $Context.GuestExchangeRoot $RelativePath + $destination = Join-Path $Context.HostExchangeRoot $RelativePath + $children = Invoke-Command -Session $Session -ScriptBlock { + param($Path) + if (Test-Path $Path -PathType Container) { + @(Get-ChildItem $Path -Force | Select-Object -ExpandProperty FullName) + } + else { + @() + } + } -ArgumentList $source + if (@($children).Count -eq 0) { + return + } + + New-Item $destination -ItemType Directory -Force | Out-Null + foreach ($child in $children) { + Copy-Item $child -Destination $destination -FromSession $Session -Recurse -Force + } +} + +function Remove-GuestItem { + <# + .SYNOPSIS + Deletes an exchange-relative path in the guest after its evidence has been exported. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string]$RelativePath + ) + + Invoke-Command -Session $Session -ScriptBlock { + param($Path) + Remove-Item $Path -Recurse -Force -ErrorAction SilentlyContinue + } -ArgumentList (Join-Path $Context.GuestExchangeRoot $RelativePath) +} + +function Write-GuestText { + <# + .SYNOPSIS + Writes a UTF-8 text file at an exchange-relative path on both sides. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string]$RelativePath, + [Parameter(Mandatory)][AllowEmptyString()][string]$Value + ) + + $hostPath = Join-Path $Context.HostExchangeRoot $RelativePath + New-Item (Split-Path $hostPath -Parent) -ItemType Directory -Force | Out-Null + $Value | Set-Content $hostPath -Encoding utf8 + + Invoke-Command -Session $Session -ScriptBlock { + param($Path, $Text) + + $ErrorActionPreference = 'Stop' + New-Item (Split-Path $Path -Parent) -ItemType Directory -Force | Out-Null + Set-Content -Path $Path -Value $Text -Encoding utf8 + } -ArgumentList (Join-Path $Context.GuestExchangeRoot $RelativePath), $Value +} + +function Read-GuestJson { + <# + .SYNOPSIS + Reads an exchange-relative JSON file, tolerating the create/write race. + + .OUTPUTS + The parsed object, or $null when the file is absent or not yet complete. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][psobject]$Context, + [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session, + [Parameter(Mandatory)][string]$RelativePath, + [ValidateRange(1, 100)][int]$Attempts = 1 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + try { + $text = Invoke-Command -Session $Session -ScriptBlock { + param($Path) + if (Test-Path $Path -PathType Leaf) { + Get-Content $Path -Raw -ErrorAction SilentlyContinue + } + } -ArgumentList (Join-Path $Context.GuestExchangeRoot $RelativePath) + + if (-not [string]::IsNullOrWhiteSpace($text)) { + return $text | ConvertFrom-Json + } + } + catch { + } + if ($attempt -lt $Attempts) { + Start-Sleep -Milliseconds 100 + } + } + + return $null +} + +Export-ModuleMember -Function ` + Test-HyperVAccess, Get-HyperVAccessMessage, Test-LocalVmHostSetup, Get-LocalVmSetupMessage, ` + New-LocalVmContext, New-LocalVmSession, ` + Initialize-GuestExchange, Copy-ToGuest, Copy-FromGuest, Remove-GuestItem, Write-GuestText, ` + Read-GuestJson diff --git a/.github/skills/ui-tests-local-vm/scripts/Update-LocalVmGuest.ps1 b/.github/skills/ui-tests-local-vm/scripts/Update-LocalVmGuest.ps1 new file mode 100644 index 000000000000..6a04bb5e4d34 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/scripts/Update-LocalVmGuest.ps1 @@ -0,0 +1,147 @@ +# 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. + +<# +.SYNOPSIS +Installs Windows updates in a local Hyper-V UI-test guest and handles required reboots. + +.DESCRIPTION +Retail Windows 10 22H2 media starts at an unserviced build that .NET 10 rejects with: +"Your Windows doesn't fully support CET. Please install all available Windows updates." +This script drives the in-box Windows Update COM API over PowerShell Direct, reboots as needed, and +repeats until no software updates remain or MaxPasses is reached. + +.EXAMPLE +pwsh ./Update-LocalVmGuest.ps1 -VmName PowerToysUiTest-Win10 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$VmName, + [string]$CredentialPath = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml'), + [ValidateRange(1, 10)] + [int]$MaxPasses = 4, + [ValidateRange(1, 120)] + [int]$ReconnectTimeoutMinutes = 30 +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this script with PowerShell 7 (pwsh).' +} +if (-not (Test-Path $CredentialPath -PathType Leaf)) { + throw "DPAPI credential file was not found: $CredentialPath" +} + +Import-Module Hyper-V -ErrorAction Stop +$credential = Import-Clixml $CredentialPath + +function New-GuestSession { + $deadline = [DateTime]::UtcNow.AddMinutes($ReconnectTimeoutMinutes) + do { + try { + return New-PSSession -VMName $VmName -Credential $credential -ErrorAction Stop + } + catch { + if ([DateTime]::UtcNow -ge $deadline) { + throw "PowerShell Direct did not reconnect to '$VmName' within $ReconnectTimeoutMinutes minute(s): $($_.Exception.Message)" + } + Start-Sleep -Seconds 5 + } + } while ($true) +} + +$vm = Get-VM -Name $VmName -ErrorAction Stop +if ($vm.State -ne 'Running') { + Start-VM -Name $VmName +} + +$passes = @() +for ($pass = 1; $pass -le $MaxPasses; $pass++) { + $session = New-GuestSession + try { + $result = Invoke-Command -Session $session -ScriptBlock { + $ErrorActionPreference = 'Stop' + $updateSession = New-Object -ComObject Microsoft.Update.Session + $searcher = $updateSession.CreateUpdateSearcher() + $search = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0") + $titles = @($search.Updates | ForEach-Object Title) + + if ($search.Updates.Count -eq 0) { + $ubr = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR + return [pscustomobject]@{ + Count = 0 + Titles = @() + DownloadResult = $null + InstallResult = $null + RebootRequired = $false + Version = "$([Environment]::OSVersion.Version.Major).$([Environment]::OSVersion.Version.Minor).$([Environment]::OSVersion.Version.Build).$ubr" + } + } + + $updates = New-Object -ComObject Microsoft.Update.UpdateColl + foreach ($update in $search.Updates) { + if (-not $update.EulaAccepted) { $update.AcceptEula() } + [void]$updates.Add($update) + } + + $downloader = $updateSession.CreateUpdateDownloader() + $downloader.Updates = $updates + $download = $downloader.Download() + + $installer = $updateSession.CreateUpdateInstaller() + $installer.Updates = $updates + $install = $installer.Install() + + $ubr = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR + [pscustomobject]@{ + Count = $updates.Count + Titles = $titles + DownloadResult = [int]$download.ResultCode + InstallResult = [int]$install.ResultCode + RebootRequired = [bool]$install.RebootRequired + Version = "$([Environment]::OSVersion.Version.Major).$([Environment]::OSVersion.Version.Minor).$([Environment]::OSVersion.Version.Build).$ubr" + } + } + } + finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + } + + $passes += $result + Write-Host "Windows Update pass $pass`: $($result.Count) update(s), download=$($result.DownloadResult), install=$($result.InstallResult), version=$($result.Version)" + foreach ($title in $result.Titles) { Write-Host " $title" } + + if ($result.Count -eq 0) { + break + } + if ($result.DownloadResult -notin 2, 3 -or $result.InstallResult -notin 2, 3) { + throw "Windows Update failed in '$VmName' (download=$($result.DownloadResult), install=$($result.InstallResult))." + } + + if ($result.RebootRequired) { + Write-Host "Restarting '$VmName'..." + $session = New-GuestSession + try { + Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue + } + finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + } + $null = New-GuestSession | ForEach-Object { Remove-PSSession $_ } + } +} + +if ($passes.Count -eq $MaxPasses -and $passes[-1].Count -gt 0) { + throw "Windows Update still found updates after $MaxPasses passes in '$VmName'. Re-run the script." +} + +[pscustomobject]@{ + VmName = $VmName + Passes = $passes.Count + FinalVersion = $passes[-1].Version + PendingUpdates = $passes[-1].Count +} | ConvertTo-Json -Depth 4 diff --git a/.github/skills/ui-tests-local-vm/templates/oem/Provision-UiTestVm.ps1 b/.github/skills/ui-tests-local-vm/templates/oem/Provision-UiTestVm.ps1 new file mode 100644 index 000000000000..965f63ed50d3 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/oem/Provision-UiTestVm.ps1 @@ -0,0 +1,168 @@ +<# +.SYNOPSIS +Provisions the PowerToys UI-test guest: standard-user desktop, auto-logon, and optional tooling. + +.DESCRIPTION +The host reaches this guest over PowerShell Direct, so no remote listener, no firewall opening, and +no certificate is created. The guest keeps its default inbound posture. +#> + +[CmdletBinding()] +param( + [string]$StandardUser = 'PTUser' +) + +$ErrorActionPreference = 'Stop' + +$standardUser = $StandardUser +$workRoot = 'C:\PowerToysUiTestRun' + +function Invoke-OfflineInstaller { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string[]]$Arguments + ) + + $process = Start-Process $Path -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "Installer '$Path' failed with exit code $($process.ExitCode)." + } +} + +$autoLogonScript = 'C:\OEM\Set-UiTestAutoLogon.ps1' +if (-not (Test-Path $autoLogonScript -PathType Leaf)) { + throw "Auto-logon provisioning helper was not found: $autoLogonScript" +} +& $autoLogonScript -StandardUser $standardUser | Out-Null +Remove-LocalGroupMember -Group 'Administrators' -Member $standardUser -ErrorAction SilentlyContinue +if ($null -eq (Get-LocalGroupMember -Group 'Users' -Member $standardUser -ErrorAction SilentlyContinue)) { + Add-LocalGroupMember -Group 'Users' -Member $standardUser +} +$remoteDesktopUsers = Get-LocalGroup -SID 'S-1-5-32-555' -ErrorAction SilentlyContinue +if ($null -ne $remoteDesktopUsers -and + $null -eq (Get-LocalGroupMember -Group $remoteDesktopUsers.Name -Member $standardUser -ErrorAction SilentlyContinue)) { + Add-LocalGroupMember -Group $remoteDesktopUsers.Name -Member $standardUser +} + +New-Item $workRoot -ItemType Directory -Force | Out-Null +$acl = Get-Acl $workRoot +$rule = [Security.AccessControl.FileSystemAccessRule]::new( + "$env:COMPUTERNAME\$standardUser", + [Security.AccessControl.FileSystemRights]::Modify, + [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow) +$acl.SetAccessRule($rule) +Set-Acl $workRoot $acl + +powercfg.exe /change monitor-timeout-ac 0 | Out-Null +powercfg.exe /change standby-timeout-ac 0 | Out-Null +powercfg.exe /hibernate off | Out-Null + +# Display settings belong to the interactive session, which does not exist yet while provisioning +# runs, so apply them from a logon task in the standard user's own session instead. +$resolutionScript = Join-Path $workRoot 'Set-GuestResolution.ps1' +$resolutionTaskRegistered = $false +if (Test-Path C:\OEM\Set-GuestResolution.ps1 -PathType Leaf) { + Copy-Item C:\OEM\Set-GuestResolution.ps1 $resolutionScript -Force + $resolutionAction = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoLogo -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$resolutionScript`"" + $resolutionTrigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:COMPUTERNAME\$standardUser" + $resolutionPrincipal = New-ScheduledTaskPrincipal ` + -UserId "$env:COMPUTERNAME\$standardUser" -LogonType Interactive -RunLevel Limited + $resolutionSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) + Register-ScheduledTask -TaskName 'PowerToysUiTest-Resolution' -Force -InputObject (New-ScheduledTask ` + -Action $resolutionAction -Trigger $resolutionTrigger ` + -Principal $resolutionPrincipal -Settings $resolutionSettings) | Out-Null + $resolutionTaskRegistered = $true +} + +$dotNetSdk = Get-ChildItem C:\OEM -Filter 'dotnet-sdk-10*-win-*.exe' -File | Select-Object -First 1 +if ($null -ne $dotNetSdk) { + Invoke-OfflineInstaller -Path $dotNetSdk.FullName -Arguments @('/install', '/quiet', '/norestart') +} +else { + $desktopRuntime = Get-ChildItem C:\OEM -Filter 'windowsdesktop-runtime-10*-win-*.exe' -File | Select-Object -First 1 + if ($null -ne $desktopRuntime) { + Invoke-OfflineInstaller -Path $desktopRuntime.FullName -Arguments @('/install', '/quiet', '/norestart') + } +} + +$webView2Installer = Get-ChildItem C:\OEM -Filter 'MicrosoftEdgeWebView2RuntimeInstaller*.exe' -File | Select-Object -First 1 +if ($null -ne $webView2Installer) { + Invoke-OfflineInstaller -Path $webView2Installer.FullName -Arguments @('/silent', '/install') +} + +# ScreenRecorderLib is a mixed-mode assembly importing VCRUNTIME140/MSVCP140. A clean Windows image +# has neither, so without this the harness silently captures no video. +$vcRedist = Get-ChildItem C:\OEM -Filter 'vc_redist.*.exe' -File | Select-Object -First 1 +if ($null -ne $vcRedist) { + $vcSignature = Get-AuthenticodeSignature $vcRedist.FullName + if ($vcSignature.Status -ne 'Valid' -or + $vcSignature.SignerCertificate.Subject -notlike 'CN=Microsoft Corporation*') { + throw "Refusing untrusted Visual C++ redistributable '$($vcRedist.FullName)'." + } + Invoke-OfflineInstaller -Path $vcRedist.FullName -Arguments @('/install', '/quiet', '/norestart') +} + +$powerShellArchitecture = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' } +$availablePowerShellMsis = @(Get-ChildItem C:\OEM -Filter "PowerShell-*-win-$powerShellArchitecture.msi" -File) +$powerShellMsi = Get-ChildItem C:\OEM -Filter "PowerShell-*-win-$powerShellArchitecture.msi" -File | + Where-Object { Test-Path "$($_.FullName).sha256" -PathType Leaf } | + Sort-Object Name -Descending | + Select-Object -First 1 +if ($availablePowerShellMsis.Count -gt 0 -and $null -eq $powerShellMsi) { + throw "PowerShell MSI trust metadata is missing. Re-stage the OEM payload with Initialize-LocalVmHost.ps1." +} +if ($null -ne $powerShellMsi) { + $expectedPowerShellHash = (Get-Content "$($powerShellMsi.FullName).sha256" -Raw).Trim() + $actualPowerShellHash = (Get-FileHash $powerShellMsi.FullName -Algorithm SHA256).Hash + $powerShellSignature = Get-AuthenticodeSignature $powerShellMsi.FullName + if ($actualPowerShellHash -ne $expectedPowerShellHash -or + $powerShellSignature.Status -ne 'Valid' -or + $powerShellSignature.SignerCertificate.Subject -notlike 'CN=Microsoft Corporation*') { + throw "Refusing unverified PowerShell MSI '$($powerShellMsi.FullName)'." + } + Invoke-OfflineInstaller -Path msiexec.exe -Arguments @( + '/i', $powerShellMsi.FullName, '/qn', '/norestart', + 'ADD_PATH=1', 'REGISTER_MANIFEST=1', + 'ENABLE_PSREMOTING=0', 'USE_MU=0', 'ENABLE_MU=0') +} +$powerShellExecutable = 'C:\Program Files\PowerShell\7\pwsh.exe' + +$windowsApplicationId = '55c92734-d682-4d71-983e-d6ec3f16059f' +$windowsLicense = Get-CimInstance SoftwareLicensingProduct -ErrorAction SilentlyContinue | + Where-Object { + $_.ApplicationID -eq $windowsApplicationId -and + -not [string]::IsNullOrWhiteSpace($_.PartialProductKey) -and + $_.Name -like 'Windows*' + } | + Select-Object -First 1 +$windowsVersion = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' +$fullWindowsBuild = "$($windowsVersion.CurrentBuild).$($windowsVersion.UBR)" +$windowsBuild = [int]$windowsVersion.CurrentBuild +$dotNet10CetReady = $windowsBuild -ge 22000 -or + ($windowsBuild -ge 19041 -and $windowsBuild -le 19045 -and [int]$windowsVersion.UBR -ge 5007) + +[ordered]@{ + ProvisionedUtc = [DateTime]::UtcNow.ToString('O') + ComputerName = $env:COMPUTERNAME + StandardUser = $standardUser + StandardUserIsAdministrator = $false + WorkRoot = $workRoot + ResolutionTaskRegistered = $resolutionTaskRegistered + DotNetSdkInstaller = if ($null -ne $dotNetSdk) { $dotNetSdk.Name } else { $null } + WebView2Installer = if ($null -ne $webView2Installer) { $webView2Installer.Name } else { $null } + VcRedistInstaller = if ($null -ne $vcRedist) { $vcRedist.Name } else { $null } + ScreenRecordingSupported = (Test-Path "$env:WINDIR\System32\VCRUNTIME140.dll") + PowerShellVersion = if (Test-Path $powerShellExecutable) { + (& $powerShellExecutable -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion.ToString()').Trim() + } else { $null } + WindowsBuild = $fullWindowsBuild + DotNet10CetReady = $dotNet10CetReady + WindowsLicenseDescription = [string]$windowsLicense.Description + WindowsLicenseStatus = [int]$windowsLicense.LicenseStatus + WindowsGracePeriodMinutes = [int]$windowsLicense.GracePeriodRemaining +} | ConvertTo-Json | Set-Content C:\OEM\ProvisioningReady.json -Encoding utf8 diff --git a/.github/skills/ui-tests-local-vm/templates/oem/Set-GuestResolution.ps1 b/.github/skills/ui-tests-local-vm/templates/oem/Set-GuestResolution.ps1 new file mode 100644 index 000000000000..33b7d442bf78 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/oem/Set-GuestResolution.ps1 @@ -0,0 +1,90 @@ +# Sets the interactive desktop resolution for the UI-test guest. +# Runs inside the guest as the standard user via an interactive scheduled task: display settings +# belong to the interactive session, so a PowerShell Direct session (session 0) cannot change them. + +[CmdletBinding()] +param( + [int]$Width = 1920, + [int]$Height = 1080 +) + +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class DisplayConfiguration +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct DEVMODE + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName; + public short dmSpecVersion; + public short dmDriverVersion; + public short dmSize; + public short dmDriverExtra; + public int dmFields; + public int dmPositionX; + public int dmPositionY; + public int dmDisplayOrientation; + public int dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName; + public short dmLogPixels; + public int dmBitsPerPel; + public int dmPelsWidth; + public int dmPelsHeight; + public int dmDisplayFlags; + public int dmDisplayFrequency; + public int dmICMMethod; + public int dmICMIntent; + public int dmMediaType; + public int dmDitherType; + public int dmReserved1; + public int dmReserved2; + public int dmPanningWidth; + public int dmPanningHeight; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int ChangeDisplaySettings(ref DEVMODE devMode, int flags); +} +'@ + +$mode = New-Object DisplayConfiguration+DEVMODE +# Pass the instance: Marshal::SizeOf binds its object overload, and a Type argument is rejected. +$mode.dmSize = [int16][Runtime.InteropServices.Marshal]::SizeOf($mode) + +# [NullString]::Value marshals as a real NULL; PowerShell would turn $null into an empty string, +# and EnumDisplaySettings needs NULL to mean "the current display device". +if ([DisplayConfiguration]::EnumDisplaySettings([NullString]::Value, -1, [ref]$mode) -eq 0) { + throw 'EnumDisplaySettings failed.' +} + +$before = "$($mode.dmPelsWidth)x$($mode.dmPelsHeight)" +$mode.dmPelsWidth = $Width +$mode.dmPelsHeight = $Height +# DM_PELSWIDTH | DM_PELSHEIGHT +$mode.dmFields = 0x00080000 -bor 0x00100000 + +# CDS_UPDATEREGISTRY makes the change persist for later logons. +$result = [DisplayConfiguration]::ChangeDisplaySettings([ref]$mode, 0x00000001) + +Add-Type -AssemblyName System.Windows.Forms +$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds +[ordered]@{ + Before = $before + Requested = "${Width}x${Height}" + ChangeDisplaySettingsResult = $result + After = "$($bounds.Width)x$($bounds.Height)" + User = [Security.Principal.WindowsIdentity]::GetCurrent().Name + SessionId = (Get-Process -Id $PID).SessionId +} | ConvertTo-Json | Set-Content C:\PowerToysUiTestRun\set-resolution.json -Encoding utf8 diff --git a/.github/skills/ui-tests-local-vm/templates/oem/Set-UiTestAutoLogon.ps1 b/.github/skills/ui-tests-local-vm/templates/oem/Set-UiTestAutoLogon.ps1 new file mode 100644 index 000000000000..f598dac2a48c --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/oem/Set-UiTestAutoLogon.ps1 @@ -0,0 +1,209 @@ +# 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. + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$StandardUser, + [string]$Description = 'PowerToys standard-user UI-test account' +) + +$ErrorActionPreference = 'Stop' + +if ($null -eq ('PowerToysUiTestAutoLogon' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class PowerToysUiTestAutoLogon +{ + [StructLayout(LayoutKind.Sequential)] + private struct LsaUnicodeString + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LsaObjectAttributes + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [DllImport("advapi32.dll")] + private static extern uint LsaOpenPolicy(IntPtr systemName, ref LsaObjectAttributes attributes, uint access, out IntPtr policy); + + [DllImport("advapi32.dll")] + private static extern uint LsaStorePrivateData(IntPtr policy, ref LsaUnicodeString key, ref LsaUnicodeString value); + + [DllImport("advapi32.dll")] + private static extern uint LsaRetrievePrivateData(IntPtr policy, ref LsaUnicodeString key, out IntPtr value); + + [DllImport("advapi32.dll")] + private static extern uint LsaFreeMemory(IntPtr buffer); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool LogonUser(string userName, string domain, string password, int logonType, int provider, out IntPtr token); + + [DllImport("advapi32.dll")] + private static extern uint LsaClose(IntPtr policy); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + + private static LsaUnicodeString CreateString(string value) + { + return new LsaUnicodeString + { + Length = (ushort)(value.Length * 2), + MaximumLength = (ushort)((value.Length + 1) * 2), + Buffer = Marshal.StringToHGlobalUni(value), + }; + } + + public static uint StorePassword(string password) + { + var attributes = new LsaObjectAttributes { Length = Marshal.SizeOf(typeof(LsaObjectAttributes)) }; + IntPtr policy; + var status = LsaOpenPolicy(IntPtr.Zero, ref attributes, 0x20, out policy); + if (status != 0) + { + return status; + } + + var key = CreateString("DefaultPassword"); + var value = CreateString(password); + try + { + return LsaStorePrivateData(policy, ref key, ref value); + } + finally + { + Marshal.FreeHGlobal(key.Buffer); + Marshal.FreeHGlobal(value.Buffer); + LsaClose(policy); + } + } + + public static string ReadPassword() + { + var attributes = new LsaObjectAttributes { Length = Marshal.SizeOf(typeof(LsaObjectAttributes)) }; + IntPtr policy; + var status = LsaOpenPolicy(IntPtr.Zero, ref attributes, 0x4, out policy); + if (status != 0) + { + return null; + } + + var key = CreateString("DefaultPassword"); + try + { + IntPtr value; + status = LsaRetrievePrivateData(policy, ref key, out value); + if (status != 0) + { + return null; + } + + try + { + var secret = (LsaUnicodeString)Marshal.PtrToStructure(value, typeof(LsaUnicodeString)); + return secret.Buffer == IntPtr.Zero ? null : Marshal.PtrToStringUni(secret.Buffer, secret.Length / 2); + } + finally + { + LsaFreeMemory(value); + } + } + finally + { + Marshal.FreeHGlobal(key.Buffer); + LsaClose(policy); + } + } + + public static bool ValidatePassword(string userName, string domain, string password, out int errorCode) + { + IntPtr token; + var valid = LogonUser(userName, domain, password, 2, 0, out token); + errorCode = valid ? 0 : Marshal.GetLastWin32Error(); + if (token != IntPtr.Zero) + { + CloseHandle(token); + } + + return valid; + } +} +'@ +} + +$localUser = Get-LocalUser -Name $StandardUser -ErrorAction SilentlyContinue +$plainPassword = if ($null -ne $localUser) { [PowerToysUiTestAutoLogon]::ReadPassword() } else { $null } +$credentialError = 0 +$credentialValid = -not [string]::IsNullOrEmpty($plainPassword) -and + [PowerToysUiTestAutoLogon]::ValidatePassword( + $StandardUser, + $env:COMPUTERNAME, + $plainPassword, + [ref]$credentialError) +$credentialRotated = $false +if (-not $credentialValid) { + $passwordBytes = New-Object byte[] 12 + $random = [Security.Cryptography.RandomNumberGenerator]::Create() + try { + $random.GetBytes($passwordBytes) + } + finally { + $random.Dispose() + } + $plainPassword = ([BitConverter]::ToString($passwordBytes) -replace '-', '') + 'aA1!' + $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force + if ($null -eq $localUser) { + New-LocalUser ` + -Name $StandardUser ` + -Password $securePassword ` + -AccountNeverExpires ` + -PasswordNeverExpires ` + -Description $Description | Out-Null + } + else { + Set-LocalUser -Name $StandardUser -Password $securePassword + } + + $credentialError = 0 + if (-not [PowerToysUiTestAutoLogon]::ValidatePassword( + $StandardUser, + $env:COMPUTERNAME, + $plainPassword, + [ref]$credentialError)) { + throw "The generated auto-logon credential did not authenticate (Win32 error $credentialError)." + } + $lsaStatus = [PowerToysUiTestAutoLogon]::StorePassword($plainPassword) + if ($lsaStatus -ne 0) { + throw "LsaStorePrivateData failed with NTSTATUS 0x$($lsaStatus.ToString('X8'))." + } + $credentialRotated = $true +} +Set-LocalUser -Name $StandardUser -PasswordNeverExpires $true + +$winlogon = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +Set-ItemProperty $winlogon AutoAdminLogon '1' +Set-ItemProperty $winlogon ForceAutoLogon '1' +Set-ItemProperty $winlogon DefaultUserName $StandardUser +Set-ItemProperty $winlogon DefaultDomainName $env:COMPUTERNAME +Remove-ItemProperty $winlogon DefaultPassword -ErrorAction SilentlyContinue +Remove-ItemProperty $winlogon AutoLogonCount -ErrorAction SilentlyContinue + +[pscustomobject]@{ + StandardUser = $StandardUser + CredentialValidated = $true + CredentialRotated = $credentialRotated +} \ No newline at end of file diff --git a/.github/skills/ui-tests-local-vm/templates/run-ui-tests.ps1 b/.github/skills/ui-tests-local-vm/templates/run-ui-tests.ps1 new file mode 100644 index 000000000000..bc2e501f80db --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/run-ui-tests.ps1 @@ -0,0 +1,510 @@ +# 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. + +<# +.SYNOPSIS +Stages and runs a PowerToys UITest.Next payload inside a persistent local Windows VM. + +.DESCRIPTION +This guest template is dispatched by Invoke-LocalVmUiTest.ps1. It reads the generated request, +extracts archives to guest-local storage, provisions optional WebView2, runs the test executable, +and exports progress, status, TRX, logs, and attachments through the shared exchange. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$RequestPath, + [switch]$Detached +) + +$ErrorActionPreference = 'Stop' +$currentPowerShell = (Get-Process -Id $PID).Path + +if ($Detached) { + $arguments = '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "{0}" -RequestPath "{1}"' -f $PSCommandPath,$RequestPath + Start-Process $currentPowerShell -ArgumentList $arguments -WindowStyle Hidden + return +} + +$request = Get-Content $RequestPath -Raw | ConvertFrom-Json +$exchangeRoot = if ($request.PSObject.Properties.Name -contains 'ExchangeRoot') { + [string]$request.ExchangeRoot +} +else { + 'C:\PowerToysUiTestExchange' +} +if ([string]::IsNullOrWhiteSpace($exchangeRoot) -or -not [IO.Path]::IsPathRooted($exchangeRoot)) { + throw 'ExchangeRoot must be an absolute path.' +} +$workRoot = 'C:\PowerToysUiTestRun' +$testRoot = Join-Path $workRoot 'Tests' +$productRoot = Join-Path $workRoot 'PowerToys' +$winAppRoot = Join-Path $workRoot 'winappcli' +$dotNetRoot = Join-Path $workRoot 'dotnet' +$localResultsRoot = Join-Path $workRoot 'TestResults' +$localLog = Join-Path $workRoot 'local-vm-ui-tests.log' +$stagingManifestPath = Join-Path $workRoot 'staging-manifest.json' +$hostResultsRoot = Join-Path $exchangeRoot "LocalVmResults\$($request.RunId)" +$startedUtc = [DateTime]::UtcNow +$exitCode = 1 +$errorMessage = $null +$transcriptStarted = $false +$outputHeartbeatSeconds = if ($null -eq $request.OutputHeartbeatSeconds) { 0 } else { [int]$request.OutputHeartbeatSeconds } +$reusedStagedPayload = $false +$heartbeatProcess = $null +$webView2Version = $null +$exportErrors = @() + +function Write-SharedText { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string]$Value + ) + + for ($attempt = 1; $attempt -le 20; $attempt++) { + try { + $Value | Set-Content $Path -Encoding utf8 + return + } + catch [IO.IOException] { + if ($attempt -eq 20) { + throw + } + [Threading.Thread]::Sleep(100) + } + } +} + +function Write-RunProgress { + param( + [Parameter(Mandatory)] + [string]$Stage, + [string]$Detail + ) + + New-Item $hostResultsRoot -ItemType Directory -Force | Out-Null + $payload = [ordered]@{ + Stage = $Stage + Detail = $Detail + UpdatedUtc = [DateTime]::UtcNow.ToString('O') + RunId = $request.RunId + } | ConvertTo-Json + Write-SharedText -Path (Join-Path $hostResultsRoot 'progress.json') -Value $payload +} + +function Copy-SharedItem { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string]$Destination + ) + + $sourceItem = Get-Item $Path -Force + if ($sourceItem.PSIsContainer) { + $directoryDestination = Join-Path $Destination $sourceItem.Name + New-Item $directoryDestination -ItemType Directory -Force | Out-Null + & robocopy.exe $sourceItem.FullName $directoryDestination ` + /E /R:5 /W:1 /COPY:DAT /DCOPY:DAT /XJ /NP /NFL /NDL /NJH /NJS | Out-Null + $robocopyExitCode = $LASTEXITCODE + if ($robocopyExitCode -ge 8) { + throw "robocopy failed with exit code $robocopyExitCode while exporting '$Path'." + } + return + } + + for ($attempt = 1; $attempt -le 5; $attempt++) { + try { + $fileDestination = Join-Path $Destination $sourceItem.Name + Copy-Item $Path $fileDestination -Force -ErrorAction Stop + return + } + catch { + if ($attempt -eq 5) { + throw + } + [Threading.Thread]::Sleep(200) + } + } +} + +function Expand-PayloadArchive { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string]$Destination + ) + + New-Item $Destination -ItemType Directory -Force | Out-Null + $tar = Get-Command tar.exe -ErrorAction SilentlyContinue + if ($null -eq $tar) { + Expand-Archive -Path $Path -DestinationPath $Destination -Force + return + } + + & $tar.Source -xf $Path -C $Destination + if ($LASTEXITCODE -ne 0) { + throw "tar.exe failed with exit code $LASTEXITCODE while extracting '$Path'." + } +} + +function Stop-RunProcesses { + $cleanupProcesses = @('PowerToys', 'PowerToys.Settings', 'winapp') + @($request.CleanupProcesses) + Get-Process -Name ($cleanupProcesses | Sort-Object -Unique) -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +function Start-OutputHeartbeat { + if ($outputHeartbeatSeconds -le 0) { + return $null + } + + $intervalMilliseconds = $outputHeartbeatSeconds * 1000 + $heartbeatScript = @" +while (`$true) { + Write-Output ('[GuestHeartbeat] ' + [DateTime]::UtcNow.ToString('O')) + [Threading.Thread]::Sleep($intervalMilliseconds) +} +"@ + $encodedHeartbeat = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($heartbeatScript)) + return Start-Process $currentPowerShell ` + -ArgumentList '-NoLogo','-NoProfile','-EncodedCommand',$encodedHeartbeat ` + -NoNewWindow -PassThru +} + +function Get-WebView2RuntimeVersion { + $clientId = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' + $registryPaths = @( + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\$clientId", + "HKCU:\Software\Microsoft\EdgeUpdate\Clients\$clientId" + ) + foreach ($registryPath in $registryPaths) { + try { + $versionText = [string](Get-ItemPropertyValue -Path $registryPath -Name 'pv' -ErrorAction Stop) + if (-not [string]::IsNullOrWhiteSpace($versionText) -and [version]$versionText -gt [version]'0.0.0.0') { + return $versionText + } + } + catch { + } + } + + $runtimeRoots = @( + 'C:\Program Files (x86)\Microsoft\EdgeWebView\Application', + (Join-Path $env:LOCALAPPDATA 'Microsoft\EdgeWebView\Application') + ) + foreach ($runtimeRoot in $runtimeRoots) { + if (-not (Test-Path $runtimeRoot -PathType Container)) { + continue + } + foreach ($versionDirectory in Get-ChildItem $runtimeRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending) { + try { + if ([version]$versionDirectory.Name -gt [version]'0.0.0.0' -and + (Test-Path (Join-Path $versionDirectory.FullName 'msedgewebview2.exe') -PathType Leaf)) { + return $versionDirectory.Name + } + } + catch { + } + } + } + + return $null +} + +try { + Write-RunProgress -Stage 'Starting' -Detail 'The guest runner is active.' + $heartbeatProcess = Start-OutputHeartbeat + + Stop-RunProcesses + $reuseRequested = $request.PSObject.Properties.Name -contains 'ReuseStagedPayload' -and [bool]$request.ReuseStagedPayload + $refreshTests = $true + $refreshProduct = $true + $refreshWinAppCli = $true + $refreshDotNet = $true + $refreshedComponents = @() + if ($reuseRequested) { + if (-not (Test-Path $stagingManifestPath -PathType Leaf)) { + throw 'Staged payload reuse was requested, but the guest manifest is missing.' + } + $stagingManifest = Get-Content $stagingManifestPath -Raw | ConvertFrom-Json + if ($null -eq $request.PayloadHashes -or $null -eq $stagingManifest.PayloadHashes) { + throw 'Staged payload reuse requires per-component hashes in both the request and guest manifest.' + } + + $refreshTests = $stagingManifest.PayloadHashes.Tests -ne $request.PayloadHashes.Tests -or + -not (Test-Path $testRoot -PathType Container) + $refreshProduct = $stagingManifest.PayloadHashes.Product -ne $request.PayloadHashes.Product -or + $stagingManifest.PayloadHashes.ProductOverlay -ne $request.PayloadHashes.ProductOverlay -or + -not (Test-Path $productRoot -PathType Container) + $refreshWinAppCli = $stagingManifest.PayloadHashes.WinAppCli -ne $request.PayloadHashes.WinAppCli -or + -not (Test-Path $winAppRoot -PathType Container) + $refreshDotNet = $stagingManifest.PayloadHashes.DotNet -ne $request.PayloadHashes.DotNet -or + -not (Test-Path $dotNetRoot -PathType Container) + $reusedStagedPayload = $true + $changedComponents = @() + if ($refreshTests) { $changedComponents += 'Tests' } + if ($refreshProduct) { $changedComponents += 'Product' } + if ($refreshWinAppCli) { $changedComponents += 'winappcli' } + if ($refreshDotNet) { $changedComponents += '.NET' } + $reuseDetail = if ($changedComponents.Count -eq 0) { + "Unchanged payload $($request.PayloadFingerprint)" + } + else { + "Refreshing: $($changedComponents -join ', ')" + } + Write-RunProgress -Stage 'Reusing' -Detail $reuseDetail + } + else { + Remove-Item $workRoot -Recurse -Force -ErrorAction SilentlyContinue + New-Item $workRoot -ItemType Directory -Force | Out-Null + } + + Remove-Item $localResultsRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $localLog -Force -ErrorAction SilentlyContinue + New-Item $localResultsRoot -ItemType Directory -Force | Out-Null + Start-Transcript -Path $localLog -Force | Out-Null + $transcriptStarted = $true + + $components = @( + [pscustomobject]@{ Name = 'Tests'; Refresh = $refreshTests; Archive = $request.Archives.Tests; Destination = $testRoot }, + [pscustomobject]@{ Name = 'Product'; Refresh = $refreshProduct; Archive = $request.Archives.Product; Destination = $productRoot }, + [pscustomobject]@{ Name = 'winappcli'; Refresh = $refreshWinAppCli; Archive = $request.Archives.WinAppCli; Destination = $winAppRoot }, + [pscustomobject]@{ Name = '.NET'; Refresh = $refreshDotNet; Archive = $request.Archives.DotNet; Destination = $dotNetRoot } + ) + foreach ($component in $components) { + if (-not $component.Refresh) { + continue + } + $archivePath = Join-Path $exchangeRoot $component.Archive + if (-not (Test-Path $archivePath -PathType Leaf)) { + throw "Required payload is missing: $archivePath" + } + Remove-Item $component.Destination -Recurse -Force -ErrorAction SilentlyContinue + $stage = if ($reuseRequested) { 'Refreshing' } else { 'Extracting' } + Write-RunProgress -Stage $stage -Detail "$($component.Name): $($component.Archive)" + Expand-PayloadArchive -Path $archivePath -Destination $component.Destination + $refreshedComponents += $component.Name + } + + if ($refreshProduct) { + if (-not [string]::IsNullOrWhiteSpace($request.Archives.ProductOverlay)) { + $overlayPath = Join-Path $exchangeRoot $request.Archives.ProductOverlay + Write-RunProgress -Stage 'Overlaying' -Detail $request.BuildLabel + Expand-PayloadArchive -Path $overlayPath -Destination $productRoot + } + } + + if (-not [string]::IsNullOrWhiteSpace($request.WebView2Installer)) { + $webView2Version = Get-WebView2RuntimeVersion + if ([string]::IsNullOrWhiteSpace($webView2Version)) { + $installer = Join-Path $exchangeRoot $request.WebView2Installer + if (-not (Test-Path $installer -PathType Leaf)) { + throw "WebView2 installer is missing: $installer" + } + + Write-RunProgress -Stage 'Installing' -Detail 'Microsoft Edge WebView2 Runtime (5 minute limit)' + $installerProcess = Start-Process $installer -ArgumentList '/silent','/install' -PassThru + if (-not $installerProcess.WaitForExit(300000)) { + Stop-Process -Id $installerProcess.Id -Force -ErrorAction SilentlyContinue + throw 'WebView2 installation exceeded five minutes.' + } + $webView2Version = Get-WebView2RuntimeVersion + if ([string]::IsNullOrWhiteSpace($webView2Version)) { + $installerExitCode = $installerProcess.ExitCode + $installerExitCodeHex = '0x{0:X8}' -f ([uint32]([int64]$installerExitCode -band 0xffffffffL)) + throw "WebView2 installation failed with exit code $installerExitCode ($installerExitCodeHex), and no runtime was detected." + } + } + Write-RunProgress -Stage 'Preparing' -Detail "Microsoft Edge WebView2 Runtime $webView2Version" + } + + [ordered]@{ + PayloadFingerprint = $request.PayloadFingerprint + PayloadFiles = @($request.PayloadFiles) + PayloadHashes = $request.PayloadHashes + StagedUtc = [DateTime]::UtcNow.ToString('O') + } | ConvertTo-Json -Depth 4 | Set-Content $stagingManifestPath -Encoding utf8 + + Write-RunProgress -Stage 'Preparing' -Detail 'Locating the test runner and dependencies.' + Get-ChildItem $winAppRoot -Recurse | Unblock-File -ErrorAction SilentlyContinue + + $requestedExecutables = if ($request.PSObject.Properties.Name -contains 'TestExecutables') { + @($request.TestExecutables) + } + elseif ($request.PSObject.Properties.Name -contains 'TestExecutable') { + @($request.TestExecutable) + } + else { + @() + } + if ($requestedExecutables.Count -eq 0) { + throw 'No test executables were requested.' + } + + $testExecutables = @() + foreach ($requestedExecutable in $requestedExecutables) { + $testExe = Get-ChildItem $testRoot -Recurse -Filter $requestedExecutable -File | Select-Object -First 1 + if ($null -eq $testExe) { + throw "$requestedExecutable was not found under $testRoot." + } + $testExecutables += $testExe + } + + $winApp = Get-ChildItem $winAppRoot -Recurse -Filter 'winapp.exe' -File | Select-Object -First 1 + if ($null -eq $winApp) { + throw "winapp.exe was not found under $winAppRoot." + } + if (-not (Test-Path (Join-Path $dotNetRoot 'dotnet.exe'))) { + throw "dotnet.exe was not found under $dotNetRoot." + } + if (-not (Test-Path (Join-Path $productRoot 'PowerToys.exe'))) { + throw "PowerToys.exe was not found under $productRoot." + } + + $env:POWERTOYS_INSTALL_DIR = $productRoot + $env:WINAPP_CLI_PATH = $winApp.FullName + $env:WINAPP_CLI_INVOKE_TIMEOUT_SECONDS = '180' + $env:DOTNET_ROOT = $dotNetRoot + $env:PATH = "$dotNetRoot;$env:PATH" + $env:TF_BUILD = 'true' + $env:platform = $request.Platform + $env:TESTINGPLATFORM_TELEMETRY_OPTOUT = '1' + + $overallExitCode = 0 + for ($testIndex = 0; $testIndex -lt $testExecutables.Count; $testIndex++) { + $testExe = $testExecutables[$testIndex] + $testArguments = @( + '--report-trx', + '--report-trx-filename', "$($testExe.BaseName).trx", + '--results-directory', $localResultsRoot, + '--timeout', $request.SuiteTimeout + ) + $effectiveFilter = $null + if (-not [string]::IsNullOrWhiteSpace($request.Filter)) { + $effectiveFilter = if ($request.Filter -match '[=~!&|()]') { $request.Filter } else { "Name=$($request.Filter)" } + $testArguments += @('--filter', $effectiveFilter) + } + + $testDetail = "$($testExe.Name) ($($testIndex + 1)/$($testExecutables.Count))" + if ($effectiveFilter) { + $testDetail += ": $effectiveFilter" + } + Write-RunProgress -Stage 'Testing' -Detail $testDetail + Set-Location $testExe.DirectoryName + & $testExe.FullName @testArguments + $testExitCode = $LASTEXITCODE + $trxPath = Join-Path $localResultsRoot "$($testExe.BaseName).trx" + if ($testExitCode -eq 0) { + if (-not (Test-Path $trxPath -PathType Leaf)) { + Write-Host "Test runner returned success without producing '$trxPath'." + $testExitCode = 1 + } + else { + [xml]$trx = Get-Content $trxPath -Raw + $counters = $trx.TestRun.ResultSummary.Counters + $total = [int]$counters.total + $executed = [int]$counters.executed + if ($total -eq 0 -or $executed -ne $total) { + Write-Host "Incomplete test run: total=$total, executed=$executed, notExecuted=$([int]$counters.notExecuted)." + $testExitCode = 1 + } + } + } + if ($testExitCode -ne 0 -and $overallExitCode -eq 0) { + $overallExitCode = $testExitCode + } + + Stop-RunProcesses + } + $exitCode = $overallExitCode +} +catch { + $errorMessage = $_.Exception.Message + Write-Host "Local VM guest runner failed: $errorMessage" +} +finally { + if ($null -ne $heartbeatProcess) { + Stop-Process -Id $heartbeatProcess.Id -Force -ErrorAction SilentlyContinue + $heartbeatProcess.Dispose() + } + Stop-RunProcesses + + if ($transcriptStarted) { + Stop-Transcript -ErrorAction SilentlyContinue | Out-Null + } + + New-Item $hostResultsRoot -ItemType Directory -Force | Out-Null + if (Test-Path $localResultsRoot) { + $hostTestResultsRoot = Join-Path $hostResultsRoot 'TestResults' + New-Item $hostTestResultsRoot -ItemType Directory -Force | Out-Null + foreach ($resultItem in Get-ChildItem $localResultsRoot -Force) { + try { + Copy-SharedItem -Path $resultItem.FullName -Destination $hostTestResultsRoot + } + catch { + $exportErrors += "Failed to export '$($resultItem.FullName)': $($_.Exception.Message)" + } + } + } + if (Test-Path $localLog) { + try { + Copy-SharedItem -Path $localLog -Destination $hostResultsRoot + } + catch { + $exportErrors += "Failed to export '$localLog': $($_.Exception.Message)" + } + } + if ($exportErrors.Count -gt 0) { + if ($exitCode -eq 0) { + $exitCode = 1 + } + $exportErrorMessage = $exportErrors -join ' ' + $errorMessage = if ([string]::IsNullOrWhiteSpace($errorMessage)) { + $exportErrorMessage + } + else { + "$errorMessage $exportErrorMessage" + } + } + + $status = [ordered]@{ + Status = if ($exitCode -eq 0) { 'PASS' } else { 'FAIL' } + ExitCode = $exitCode + Error = $errorMessage + ExportErrors = @($exportErrors) + BuildLabel = $request.BuildLabel + Filter = $request.Filter + Platform = $request.Platform + OutputHeartbeatSeconds = $outputHeartbeatSeconds + WebView2Version = $webView2Version + DesktopWidth = 0 + DesktopHeight = 0 + ReusedStagedPayload = $reusedStagedPayload + RefreshedComponents = $refreshedComponents + PayloadFingerprint = $request.PayloadFingerprint + RunId = $request.RunId + StartedUtc = $startedUtc.ToString('O') + CompletedUtc = [DateTime]::UtcNow.ToString('O') + User = [Security.Principal.WindowsIdentity]::GetCurrent().Name + SessionId = (Get-Process -Id $PID).SessionId + OsVersion = [Environment]::OSVersion.Version.ToString() + } + try { + Add-Type -AssemblyName System.Windows.Forms + $desktopBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $status.DesktopWidth = $desktopBounds.Width + $status.DesktopHeight = $desktopBounds.Height + } + catch { + } + Write-SharedText -Path (Join-Path $hostResultsRoot 'status.json') -Value ($status | ConvertTo-Json) + Write-RunProgress -Stage 'Completed' -Detail $status.Status +} + +exit $exitCode diff --git a/.github/skills/ui-tests-local-vm/templates/vm/.gitignore b/.github/skills/ui-tests-local-vm/templates/vm/.gitignore new file mode 100644 index 000000000000..cc7d1f82c061 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/.gitignore @@ -0,0 +1,5 @@ +vm.config.psd1 +vm/ +shared/ +*.vhdx +*.credential.xml diff --git a/.github/skills/ui-tests-local-vm/templates/vm/New-UiTestVm.ps1 b/.github/skills/ui-tests-local-vm/templates/vm/New-UiTestVm.ps1 new file mode 100644 index 000000000000..8e5c219bc80f --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/New-UiTestVm.ps1 @@ -0,0 +1,585 @@ +# 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. + +<# +.SYNOPSIS +Builds a persistent Hyper-V guest for PowerToys UI tests by running Windows Setup inside the guest. + +.DESCRIPTION +Creates an empty virtual disk, attaches the Windows installation media plus a generated answer-file +ISO, and lets Windows Setup partition and install from inside the virtual machine. Nothing about the +guest disk is ever prepared from the host, so every boot reference Setup writes is correct by +construction. + +An earlier design applied the image with DISM and ran bcdboot on the host against the mounted VHDX. +That is the technique Convert-WindowsImage uses, but its goal is native-VHD boot, so bcdboot records +'vhd=[X:]\path\to.vhdx' device references. Inside a virtual machine that file does not exist - the +VHDX is the disk - and the guest fails with 0xc000000e. Repairing it from the host is not possible +either, because bcdedit resolves drive letters through the host's view and rewrites them straight +back into vhd= references. + +The administrator password is never accepted as a parameter or stored in the configuration file. It +is read from a DPAPI-protected credential file and written to the answer file using the base64 +obfuscation Windows expects, so no plaintext password reaches the media. + +.EXAMPLE +pwsh ./New-UiTestVm.ps1 -InstallMedia D:\media\Win11_25H2_English_Arm64_v2.iso -ListImages + +.EXAMPLE +pwsh ./New-UiTestVm.ps1 -InstallMedia D:\media\Win11_25H2_English_Arm64_v2.iso -ImageName 'Windows 11 Pro' +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$ConfigPath = (Join-Path $PSScriptRoot 'vm.config.psd1'), + [string]$InstallMedia, + [string]$ImageName = 'Windows 11 Pro', + [string]$CredentialPath = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml'), + [string]$OemPath = (Join-Path $PSScriptRoot 'oem'), + [ValidateRange(5, 720)] + [int]$TimeoutMinutes = 90, + [switch]$ListImages, + [switch]$PlanOnly, + [switch]$AllowReFsVolume, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this script with PowerShell 7 (pwsh).' +} + +function Test-Elevation { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + return ([Security.Principal.WindowsPrincipal]$identity).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function ConvertTo-UnattendPassword { + <# + .SYNOPSIS + Applies the base64 obfuscation Windows expects for answer-file passwords. + + .DESCRIPTION + Windows appends the name of the containing element to the password before base64-encoding the + UTF-16LE bytes. This is obfuscation, not encryption, but it keeps the plaintext off the media. + #> + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Password, + [Parameter(Mandatory)][ValidateSet('Password', 'AdministratorPassword')][string]$Element + ) + + return [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Password + $Element)) +} + +function Assert-SupportedVmVolume { + <# + .SYNOPSIS + Refuses to store the guest on a ReFS volume such as a Dev Drive. + + .DESCRIPTION + Keeping the VHDX on a Dev Drive has been observed to wedge the Hyper-V management service: + subsequent management calls, including read-only ones, never return, and recovery needs a vmms + restart or a reboot. Hyper-V on plain ReFS is supported, so this refusal is deliberately + conservative - ReFS is a cheap proxy for "Dev Drive", which cannot be detected without elevation. + Use -AllowReFsVolume on a known-good ReFS volume. Only VhdPath and VmPath are checked; the + exchange is ordinary file I/O and needs no such restriction. + #> + param( + [Parameter(Mandatory)][string]$Path, + [switch]$Allow + ) + + $root = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($Path)) + if ($root -notmatch '^(?[A-Za-z]):\\$') { + return + } + $volume = Get-Volume -DriveLetter $Matches.Letter -ErrorAction SilentlyContinue + if ($null -eq $volume -or [string]$volume.FileSystemType -ne 'ReFS') { + return + } + + $message = "Guest storage '$Path' is on a $($volume.FileSystemType) volume ($($Matches.Letter):). Prefer NTFS: hosting a VHDX on a Dev Drive has been observed to hang the Hyper-V management service." + if ($Allow) { + Write-Warning $message + return + } + throw "BLOCKED: $message Pass -AllowReFsVolume to override." +} + +function Get-MountedImageRoot { + <# + .SYNOPSIS + Returns the drive root of mounted installation media that carries a Windows image. + + .DESCRIPTION + Uses System.IO.DriveInfo rather than Get-Volume: PowerShell 7 reaches the Storage cmdlets through + the Windows PowerShell compatibility layer, where CIM enum properties do not compare reliably. + #> + param([Parameter(Mandatory)][string]$ImagePath) + + foreach ($drive in [IO.DriveInfo]::GetDrives() | Where-Object { $_.DriveType -eq 'CDRom' -and $_.IsReady }) { + $root = $drive.Name.TrimEnd('\') + foreach ($name in 'install.wim', 'install.esd') { + if (Test-Path (Join-Path $root "sources\$name") -PathType Leaf) { + return $root + } + } + } + + throw "No Windows image was found on the mounted media: $ImagePath" +} + +function Get-WindowsImageList { + param([Parameter(Mandatory)][string]$MediaRoot) + + foreach ($name in 'install.wim', 'install.esd') { + $candidate = Join-Path $MediaRoot "sources\$name" + if (Test-Path $candidate -PathType Leaf) { + return @(Get-WindowsImage -ImagePath $candidate) + } + } + + throw "No install.wim or install.esd was found under $MediaRoot\sources." +} + +function New-DataIso { + <# + .SYNOPSIS + Builds a small ISO from a folder using the in-box IMAPI2 file system image COM API. + + .DESCRIPTION + Windows Setup only reads autounattend.xml from removable media, and generation 2 virtual + machines have no floppy controller, so the answer file has to be delivered as a second optical + disc. This avoids a dependency on oscdimg from the ADK. + #> + param( + [Parameter(Mandatory)][string]$SourceFolder, + [Parameter(Mandatory)][string]$Destination, + [string]$VolumeName = 'PTUNATTEND' + ) + + if (-not ('PowerToysUiTestVm.IsoWriter' -as [type])) { + Add-Type -Namespace PowerToysUiTestVm -Name IsoWriter -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("shlwapi.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, ExactSpelling = true, PreserveSig = false)] +private static extern void SHCreateStreamOnFileEx(string fileName, uint grfMode, uint dwAttributes, + bool fCreate, System.Runtime.InteropServices.ComTypes.IStream reserved, + out System.Runtime.InteropServices.ComTypes.IStream ppstm); + +public static void Write(object imageStream, string path) +{ + System.Runtime.InteropServices.ComTypes.IStream source = + (System.Runtime.InteropServices.ComTypes.IStream)imageStream; + System.Runtime.InteropServices.ComTypes.IStream target; + // STGM_CREATE | STGM_WRITE, FILE_ATTRIBUTE_NORMAL + SHCreateStreamOnFileEx(path, 0x00001001, 0x80, true, null, out target); + source.CopyTo(target, long.MaxValue, System.IntPtr.Zero, System.IntPtr.Zero); + target.Commit(0); +} +'@ + } + + Remove-Item $Destination -Force -ErrorAction SilentlyContinue + $fileSystemImage = New-Object -ComObject IMAPI2FS.MsftFileSystemImage + try { + $fileSystemImage.FileSystemsToCreate = 3 # ISO9660 | Joliet + $fileSystemImage.VolumeName = $VolumeName + $fileSystemImage.Root.AddTree($SourceFolder, $false) + $resultImage = $fileSystemImage.CreateResultImage() + [PowerToysUiTestVm.IsoWriter]::Write($resultImage.ImageStream, $Destination) + } + finally { + [Runtime.InteropServices.Marshal]::ReleaseComObject($fileSystemImage) | Out-Null + } + + if (-not (Test-Path $Destination -PathType Leaf)) { + throw "The answer-file ISO was not created: $Destination" + } +} + +function New-UnattendContent { + param( + [Parameter(Mandatory)][hashtable]$Configuration, + [Parameter(Mandatory)][AllowEmptyString()][string]$ObfuscatedPassword, + [Parameter(Mandatory)][string]$SelectedImageName, + [Parameter(Mandatory)][string]$ProvisionArguments, + [Parameter(Mandatory)][string]$TemplatePath + ) + + if (-not (Test-Path $TemplatePath -PathType Leaf)) { + throw "Answer-file template was not found: $TemplatePath" + } + + $tokens = @{ + '{{ARCH}}' = $Configuration.ProcessorArchitecture + '{{COMPUTERNAME}}' = $Configuration.ComputerName + '{{LOCALE}}' = $Configuration.Locale + '{{TIMEZONE}}' = $Configuration.TimeZone + '{{ADMINUSER}}' = $Configuration.AdminUserName + '{{ADMINPASSWORD}}' = $ObfuscatedPassword + '{{IMAGENAME}}' = [Security.SecurityElement]::Escape($SelectedImageName) + '{{PROVISIONARGUMENTS}}' = $ProvisionArguments + } + $content = Get-Content $TemplatePath -Raw + foreach ($token in $tokens.GetEnumerator()) { + $content = $content.Replace($token.Key, [string]$token.Value) + } + if ($content -match '\{\{[A-Z]+\}\}') { + throw "The answer-file template still contains unresolved placeholders: $($Matches[0])" + } + [xml]$content | Out-Null + return $content +} + +function Get-GuestScreenLevel { + <# + .SYNOPSIS + Returns the mean intensity of the guest framebuffer, used to tell a text-mode boot prompt from a + graphical Setup screen. + + .DESCRIPTION + The firmware and the "Press any key to boot from CD or DVD" prompt are near-black; Setup's UI is + a saturated blue. Averaging the raw RGB565 bytes separates the two without decoding the image. + #> + param([Parameter(Mandatory)][string]$VmName) + + $namespace = 'root\virtualization\v2' + $service = Get-CimInstance -Namespace $namespace -ClassName Msvm_VirtualSystemManagementService + $system = Get-CimInstance -Namespace $namespace -ClassName Msvm_ComputerSystem -Filter "ElementName='$VmName'" + $settings = Get-CimAssociatedInstance -InputObject $system -ResultClassName Msvm_VirtualSystemSettingData | + Where-Object { $_.VirtualSystemType -eq 'Microsoft:Hyper-V:System:Realized' } | + Select-Object -First 1 + $result = Invoke-CimMethod -InputObject $service -MethodName GetVirtualSystemThumbnailImage -Arguments @{ + TargetSystem = [ciminstance]$settings + WidthPixels = [uint16]160 + HeightPixels = [uint16]120 + } + if ($result.ReturnValue -ne 0 -or $null -eq $result.ImageData -or $result.ImageData.Length -eq 0) { + return 0 + } + + $total = 0 + foreach ($byte in $result.ImageData) { + $total += $byte + } + return [math]::Round($total / $result.ImageData.Length, 2) +} + +function Send-GuestKey { + <# + .SYNOPSIS + Presses a key on the guest's virtual keyboard. + + .DESCRIPTION + Windows installation media prompts "Press any key to boot from CD or DVD". Nothing types that key + in an automated virtual machine, so the firmware falls through to an empty disk and the install + never starts. + #> + param( + [Parameter(Mandatory)][string]$VmName, + [uint32]$KeyCode = 0x0D, + [int]$Count = 1 + ) + + $system = Get-CimInstance -Namespace root\virtualization\v2 -ClassName Msvm_ComputerSystem ` + -Filter "ElementName='$VmName'" + $keyboard = Get-CimAssociatedInstance -InputObject $system -ResultClassName Msvm_Keyboard + for ($index = 0; $index -lt $Count; $index++) { + Invoke-CimMethod -InputObject $keyboard -MethodName TypeKey -Arguments @{ keyCode = $KeyCode } | Out-Null + Start-Sleep -Milliseconds 400 + } +} + +if (-not (Test-Path $ConfigPath -PathType Leaf)) { + throw "Configuration was not found: $ConfigPath. Copy vm.config.example.psd1 to vm.config.psd1 first." +} +$configuration = Import-PowerShellDataFile $ConfigPath +foreach ($key in 'VmName', 'ComputerName', 'VmPath', 'VhdPath', 'DiskSizeGB', 'MemoryStartupGB', + 'ProcessorCount', 'AdminUserName', 'StandardUser', 'ProcessorArchitecture', 'Locale', 'TimeZone', + 'BaselineCheckpointName') { + if (-not $configuration.ContainsKey($key) -or [string]::IsNullOrWhiteSpace([string]$configuration[$key])) { + throw "Configuration value '$key' is missing from $ConfigPath." + } +} +if ($configuration.ProcessorArchitecture -notin @('amd64', 'arm64')) { + throw "ProcessorArchitecture must be amd64 or arm64, not '$($configuration.ProcessorArchitecture)'." +} +$hostArchitecture = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() } +} +if ($configuration.ProcessorArchitecture -ne $hostArchitecture) { + throw "Hyper-V cannot run a $($configuration.ProcessorArchitecture) guest on a $hostArchitecture host." +} + +$answerIsoPath = Join-Path $configuration.VmPath 'answer-file.iso' +$answerTemplatePath = Join-Path $PSScriptRoot 'unattend\autounattend.xml.template' +Assert-SupportedVmVolume -Path $configuration.VhdPath -Allow:$AllowReFsVolume +Assert-SupportedVmVolume -Path $configuration.VmPath -Allow:$AllowReFsVolume +$plan = [ordered]@{ + VmName = $configuration.VmName + ComputerName = $configuration.ComputerName + VmPath = $configuration.VmPath + VhdPath = $configuration.VhdPath + DiskSizeGB = $configuration.DiskSizeGB + MemoryStartupGB = $configuration.MemoryStartupGB + ProcessorCount = $configuration.ProcessorCount + SwitchName = [string]$configuration.SwitchName + ProcessorArchitecture = $configuration.ProcessorArchitecture + InstallMedia = $InstallMedia + ImageName = $ImageName + AnswerIso = $answerIsoPath + OemPath = $OemPath + CredentialPath = $CredentialPath + BaselineCheckpointName = $configuration.BaselineCheckpointName +} + +if ($PlanOnly) { + $preview = New-UnattendContent -Configuration $configuration ` + -ObfuscatedPassword (ConvertTo-UnattendPassword -Password 'preview' -Element 'Password') ` + -SelectedImageName $ImageName ` + -ProvisionArguments "-StandardUser $($configuration.StandardUser)" ` + -TemplatePath $answerTemplatePath + $plan.AnswerFileBytes = $preview.Length + $plan.AnswerFileIsWellFormed = $true + [pscustomobject]$plan | ConvertTo-Json -Depth 4 + return +} + +if (-not (Test-Elevation)) { + throw 'BLOCKED: creating a Hyper-V guest requires an elevated host shell.' +} +Import-Module Hyper-V -ErrorAction Stop + +if ([string]::IsNullOrWhiteSpace($InstallMedia) -or -not (Test-Path $InstallMedia -PathType Leaf)) { + throw "Installation media was not found: $InstallMedia" +} + +if ($ListImages) { + Mount-DiskImage -ImagePath $InstallMedia -Access ReadOnly -StorageType ISO | Out-Null + try { + Get-WindowsImageList -MediaRoot (Get-MountedImageRoot -ImagePath $InstallMedia) | + Select-Object ImageIndex, ImageName, ImageDescription + } + finally { + Dismount-DiskImage -ImagePath $InstallMedia | Out-Null + } + return +} + +if (-not (Test-Path $OemPath -PathType Container)) { + throw "OEM payload folder was not found: $OemPath" +} +if (-not (Test-Path (Join-Path $OemPath 'Provision-UiTestVm.ps1') -PathType Leaf)) { + throw "Provision-UiTestVm.ps1 was not found under $OemPath." +} +if (-not (Test-Path $CredentialPath -PathType Leaf)) { + throw "DPAPI credential file was not found: $CredentialPath. Create it with Get-Credential | Export-Clixml." +} +$credential = Import-Clixml $CredentialPath +if ($credential -isnot [pscredential]) { + throw "Credential file does not contain a PSCredential: $CredentialPath" +} +$credentialUser = $credential.UserName -replace '^.*\\', '' +if ($credentialUser -ne $configuration.AdminUserName) { + throw "The credential file is for '$credentialUser', but the configuration expects '$($configuration.AdminUserName)'." +} + +$existingVm = Get-VM -Name $configuration.VmName -ErrorAction SilentlyContinue +if (($null -ne $existingVm -or (Test-Path $configuration.VhdPath -PathType Leaf)) -and -not $Force) { + throw "Virtual machine '$($configuration.VmName)' or its disk already exists. Pass -Force to replace it." +} +if (-not $PSCmdlet.ShouldProcess($configuration.VmName, 'Create the local UI-test Hyper-V guest')) { + return +} + +if ($null -ne $existingVm) { + if ($existingVm.State -ne 'Off') { + Stop-VM -Name $configuration.VmName -TurnOff -Force + } + Get-VMSnapshot -VMName $configuration.VmName -ErrorAction SilentlyContinue | Remove-VMSnapshot -Confirm:$false + Remove-VM -Name $configuration.VmName -Force +} +Remove-Item $configuration.VhdPath -Force -ErrorAction SilentlyContinue +New-Item $configuration.VmPath -ItemType Directory -Force | Out-Null +New-Item (Split-Path $configuration.VhdPath -Parent) -ItemType Directory -Force | Out-Null + +Write-Host 'Validating the requested edition on the installation media...' +Mount-DiskImage -ImagePath $InstallMedia -Access ReadOnly -StorageType ISO | Out-Null +try { + $images = Get-WindowsImageList -MediaRoot (Get-MountedImageRoot -ImagePath $InstallMedia) + $selected = $images | Where-Object { $_.ImageName -eq $ImageName } | Select-Object -First 1 + if ($null -eq $selected) { + throw "Edition '$ImageName' is not on this media. Available: $(($images | ForEach-Object { $_.ImageName }) -join ' | ')" + } + Write-Host "Selected image $($selected.ImageIndex): $($selected.ImageName)" +} +finally { + Dismount-DiskImage -ImagePath $InstallMedia | Out-Null +} + +Write-Host 'Building the answer-file ISO...' +$stagingRoot = Join-Path ([IO.Path]::GetTempPath()) ("ptvm-answer-" + [guid]::NewGuid().ToString('N')) +try { + New-Item $stagingRoot -ItemType Directory -Force | Out-Null + $unattend = New-UnattendContent -Configuration $configuration ` + -ObfuscatedPassword (ConvertTo-UnattendPassword -Password $credential.GetNetworkCredential().Password -Element 'Password') ` + -SelectedImageName $selected.ImageName ` + -ProvisionArguments "-StandardUser $($configuration.StandardUser)" ` + -TemplatePath $answerTemplatePath + Set-Content (Join-Path $stagingRoot 'autounattend.xml') -Value $unattend -Encoding utf8 + $unattend = $null + Copy-Item $OemPath (Join-Path $stagingRoot 'OEM') -Recurse -Force + New-DataIso -SourceFolder $stagingRoot -Destination $answerIsoPath +} +finally { + Remove-Item $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Creating virtual machine '$($configuration.VmName)'..." +New-VHD -Path $configuration.VhdPath -SizeBytes ($configuration.DiskSizeGB * 1GB) -Dynamic | Out-Null +$newVmParameters = @{ + Name = $configuration.VmName + Generation = 2 + MemoryStartupBytes = ($configuration.MemoryStartupGB * 1GB) + VHDPath = $configuration.VhdPath + Path = $configuration.VmPath +} +if (-not [string]::IsNullOrWhiteSpace([string]$configuration.SwitchName)) { + $newVmParameters.SwitchName = $configuration.SwitchName +} +New-VM @newVmParameters | Out-Null + +Set-VMProcessor -VMName $configuration.VmName -Count $configuration.ProcessorCount +Set-VMMemory -VMName $configuration.VmName -DynamicMemoryEnabled $false +Set-VM -Name $configuration.VmName ` + -AutomaticCheckpointsEnabled $false -CheckpointType Standard ` + -AutomaticStartAction Nothing -AutomaticStopAction ShutDown +Set-VMFirmware -VMName $configuration.VmName -EnableSecureBoot On -SecureBootTemplate MicrosoftWindows +try { + Set-VMKeyProtector -VMName $configuration.VmName -NewLocalKeyProtector -ErrorAction Stop + Enable-VMTPM -VMName $configuration.VmName -ErrorAction Stop +} +catch { + Write-Warning "The virtual TPM could not be enabled: $($_.Exception.Message)" +} +Enable-VMIntegrationService -VMName $configuration.VmName -Name 'Guest Service Interface' + +Add-VMDvdDrive -VMName $configuration.VmName -Path $InstallMedia +Add-VMDvdDrive -VMName $configuration.VmName -Path $answerIsoPath +$installDvd = Get-VMDvdDrive -VMName $configuration.VmName | Where-Object { $_.Path -eq $InstallMedia } +Set-VMFirmware -VMName $configuration.VmName -FirstBootDevice $installDvd + +Write-Host 'Starting Windows Setup in the guest...' +Start-VM -Name $configuration.VmName +# The media waits for "Press any key to boot from CD or DVD" and gives up without it. Stop as soon as +# Setup's own UI is up: Enter there activates Cancel and aborts the installation. +for ($attempt = 0; $attempt -lt 10; $attempt++) { + Start-Sleep -Seconds 2 + $screenLevel = try { Get-GuestScreenLevel -VmName $configuration.VmName } catch { 0 } + if ($screenLevel -gt 12) { + Write-Host " Setup is on screen after $($attempt * 2)s; no further key presses." + break + } + try { + Send-GuestKey -VmName $configuration.VmName -Count 1 + } + catch { + Write-Verbose "Key press failed: $($_.Exception.Message)" + } +} + +Write-Host 'Waiting for Setup and provisioning to finish...' +$deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) +$provisioning = $null +$desktopUser = $null +$lastConnectionError = $null +$lastFailureStage = 'connect' +$attempt = 0 +do { + Start-Sleep -Seconds 20 + $attempt++ + try { + $lastFailureStage = 'connect' + $session = New-PSSession -VMName $configuration.VmName -Credential $credential -ErrorAction Stop + $lastFailureStage = 'query' + try { + $guestState = Invoke-Command -Session $session -ScriptBlock { + param($InteractiveUser) + # PowerShell Direct lands on the guest's in-box PowerShell 5.1, whose parser rejects a + # multi-line statement as a hashtable value ("The hash literal was incomplete"). Build + # the values first so this scriptblock parses on 5.1 as well as 7. + $provisioningJson = $null + if (Test-Path C:\OEM\ProvisioningReady.json -PathType Leaf) { + $provisioningJson = Get-Content C:\OEM\ProvisioningReady.json -Raw + } + $interactiveExplorer = @(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.UserName -like "*\$InteractiveUser" } | + Select-Object -First 1 -ExpandProperty UserName) + [pscustomobject]@{ + Provisioning = $provisioningJson + DesktopUser = $interactiveExplorer + } + } -ArgumentList $configuration.StandardUser + $provisioning = $guestState.Provisioning + $desktopUser = @($guestState.DesktopUser) | Select-Object -First 1 + $lastConnectionError = $null + } + finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + } + } + catch { + $lastConnectionError = $_.Exception.Message + } + # Surface why the guest is still not answering; silence here hides real failures for the whole timeout. + if (($attempt % 6) -eq 0) { + if ($null -ne $lastConnectionError) { + # A guest-side failure looks nothing like an unreachable guest: reporting both as + # "not reachable" hid a scriptblock parse error until the timeout expired. + $stagePrefix = if ($lastFailureStage -eq 'connect') { 'not reachable' } else { 'reachable, but the guest query failed' } + $state = "${stagePrefix}: $lastConnectionError" + } + else { + $state = "reachable; provisioned=$(-not [string]::IsNullOrWhiteSpace($provisioning)) desktopUser='$desktopUser'" + } + Write-Host " still waiting after $([int]($attempt * 20 / 60)) minute(s) - $state" + } + # Provisioning ends with a reboot into the standard-user desktop, so both signals are required. + if (-not [string]::IsNullOrWhiteSpace($provisioning) -and -not [string]::IsNullOrWhiteSpace($desktopUser)) { + break + } + if ([DateTime]::UtcNow -ge $deadline) { + $reason = if ($null -ne $lastConnectionError) { + if ($lastFailureStage -eq 'connect') { + "the guest never answered PowerShell Direct: $lastConnectionError" + } + else { + "PowerShell Direct connected but the readiness query failed: $lastConnectionError" + } + } + elseif ([string]::IsNullOrWhiteSpace($provisioning)) { + 'C:\OEM\ProvisioningReady.json was never written' + } + else { + "no interactive Explorer session for $($configuration.StandardUser) appeared" + } + throw "The guest did not finish provisioning within $TimeoutMinutes minute(s): $reason. The virtual machine and its disk are preserved; inspect the console with Get-VmConsoleImage.ps1 or vmconnect.exe." + } +} while ($true) + +Write-Host "Interactive desktop user: $desktopUser" +Write-Host 'Detaching installation media...' +Get-VMDvdDrive -VMName $configuration.VmName | Remove-VMDvdDrive +Remove-Item $answerIsoPath -Force -ErrorAction SilentlyContinue + +Write-Host "Creating baseline checkpoint '$($configuration.BaselineCheckpointName)'..." +Checkpoint-VM -Name $configuration.VmName -SnapshotName $configuration.BaselineCheckpointName + +$plan.Provisioning = $provisioning | ConvertFrom-Json +$plan.DesktopUser = $desktopUser +$plan.Checkpoint = $configuration.BaselineCheckpointName +[pscustomobject]$plan | ConvertTo-Json -Depth 5 diff --git a/.github/skills/ui-tests-local-vm/templates/vm/Reset-LocalVm.ps1 b/.github/skills/ui-tests-local-vm/templates/vm/Reset-LocalVm.ps1 new file mode 100644 index 000000000000..309b8bc7c494 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/Reset-LocalVm.ps1 @@ -0,0 +1,106 @@ +# 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. + +<# +.SYNOPSIS +Manages clean-baseline checkpoints for the Hyper-V UI-test guest. + +.DESCRIPTION +Restoring a standard checkpoint returns the guest to the exact state it was captured in, including +the logged-on desktop, which makes it the fast equivalent of recreating the VM. Use it for every +clean-profile claim instead of trusting a long-lived, mutated guest. + +.EXAMPLE +pwsh ./Reset-LocalVm.ps1 -List + +.EXAMPLE +pwsh ./Reset-LocalVm.ps1 -Restore + +.EXAMPLE +pwsh ./Reset-LocalVm.ps1 -CreateBaseline -CheckpointName 'webview2-installed' +#> + +[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'List')] +param( + [string]$ConfigPath = (Join-Path $PSScriptRoot 'vm.config.psd1'), + + [Parameter(ParameterSetName = 'List')] + [switch]$List, + + [Parameter(Mandatory, ParameterSetName = 'Restore')] + [switch]$Restore, + + [Parameter(Mandatory, ParameterSetName = 'Create')] + [switch]$CreateBaseline, + + [Parameter(ParameterSetName = 'Restore')] + [Parameter(ParameterSetName = 'Create')] + [string]$CheckpointName, + + [Parameter(ParameterSetName = 'Restore')] + [switch]$StartAfterRestore +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $ConfigPath -PathType Leaf)) { + throw "Configuration was not found: $ConfigPath" +} +$configuration = Import-PowerShellDataFile $ConfigPath +$effectiveCheckpoint = if ([string]::IsNullOrWhiteSpace($CheckpointName)) { + $configuration.BaselineCheckpointName +} +else { + $CheckpointName +} + +try { + Import-Module Hyper-V -ErrorAction Stop + Get-VMHost -ErrorAction Stop | Out-Null +} +catch { + throw 'BLOCKED: Hyper-V is not accessible from this shell. Run from an elevated PowerShell 7 terminal, or add this account to the local "Hyper-V Administrators" group.' +} + +$vm = Get-VM -Name $configuration.VmName -ErrorAction SilentlyContinue +if ($null -eq $vm) { + throw "Virtual machine '$($configuration.VmName)' does not exist." +} + +if ($CreateBaseline) { + if ($PSCmdlet.ShouldProcess($vm.Name, "Create checkpoint '$effectiveCheckpoint'")) { + Get-VMCheckpoint -VMName $vm.Name -Name $effectiveCheckpoint -ErrorAction SilentlyContinue | + Remove-VMCheckpoint -Confirm:$false + Checkpoint-VM -Name $vm.Name -SnapshotName $effectiveCheckpoint + } +} +elseif ($Restore) { + $checkpoint = Get-VMCheckpoint -VMName $vm.Name -Name $effectiveCheckpoint -ErrorAction SilentlyContinue + if ($null -eq $checkpoint) { + throw "Checkpoint '$effectiveCheckpoint' does not exist for '$($vm.Name)'." + } + if ($PSCmdlet.ShouldProcess($vm.Name, "Restore checkpoint '$effectiveCheckpoint'")) { + if ($vm.State -eq 'Running') { + Stop-VM -Name $vm.Name -TurnOff -Force + } + Restore-VMCheckpoint -VMName $vm.Name -Name $effectiveCheckpoint -Confirm:$false + if ($StartAfterRestore) { + Start-VM -Name $vm.Name + } + } +} + +$vm = Get-VM -Name $configuration.VmName +[pscustomobject]@{ + VmName = $vm.Name + State = [string]$vm.State + BaselineCheckpointName = $configuration.BaselineCheckpointName + Checkpoints = @(Get-VMCheckpoint -VMName $vm.Name -ErrorAction SilentlyContinue | ForEach-Object { + [pscustomobject]@{ + Name = $_.Name + CreationTime = $_.CreationTime + ParentCheckpointName = $_.ParentCheckpointName + } + }) +} | ConvertTo-Json -Depth 4 diff --git a/.github/skills/ui-tests-local-vm/templates/vm/Start-LocalVm.ps1 b/.github/skills/ui-tests-local-vm/templates/vm/Start-LocalVm.ps1 new file mode 100644 index 000000000000..8c668a6c3bd4 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/Start-LocalVm.ps1 @@ -0,0 +1,170 @@ +# 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. + +<# +.SYNOPSIS +Starts the persistent Hyper-V UI-test guest and optionally waits for PowerShell Direct. + +.EXAMPLE +pwsh ./Start-LocalVm.ps1 -Wait -TimeoutMinutes 20 +#> + +[CmdletBinding()] +param( + [string]$ConfigPath = (Join-Path $PSScriptRoot 'vm.config.psd1'), + [string]$CredentialPath = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml'), + [switch]$Wait, + [ValidateRange(1, 720)] + [int]$TimeoutMinutes = 30, + [ValidateSet('Default', 'Constrained')] + [string]$ResourceProfile = 'Default', + [switch]$PlanOnly +) + +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run this script with PowerShell 7 (pwsh).' +} +if (-not (Test-Path $ConfigPath -PathType Leaf)) { + throw "Configuration was not found: $ConfigPath. Copy vm.config.example.psd1 to vm.config.psd1 first." +} +$configuration = Import-PowerShellDataFile $ConfigPath + +$memoryGB = if ($ResourceProfile -eq 'Constrained') { + if ($configuration.ContainsKey('ConstrainedMemoryStartupGB')) { $configuration.ConstrainedMemoryStartupGB } else { 4 } +} +else { + $configuration.MemoryStartupGB +} +$processorCount = if ($ResourceProfile -eq 'Constrained') { + if ($configuration.ContainsKey('ConstrainedProcessorCount')) { $configuration.ConstrainedProcessorCount } else { 1 } +} +else { + $configuration.ProcessorCount +} + +if ($PlanOnly) { + [pscustomobject]@{ + VmName = $configuration.VmName + ResourceProfile = $ResourceProfile + MemoryStartupGB = $memoryGB + ProcessorCount = $processorCount + } | ConvertTo-Json + return +} + +try { + Import-Module Hyper-V -ErrorAction Stop + Get-VMHost -ErrorAction Stop | Out-Null +} +catch { + throw 'BLOCKED: Hyper-V is not accessible from this shell. Run from an elevated PowerShell 7 terminal, or add this account to the local "Hyper-V Administrators" group.' +} + +$vm = Get-VM -Name $configuration.VmName -ErrorAction SilentlyContinue +if ($null -eq $vm) { + throw "Virtual machine '$($configuration.VmName)' does not exist. Create it with New-UiTestVm.ps1." +} + +if ($vm.State -eq 'Off') { + Set-VMMemory -VMName $vm.Name -StartupBytes ($memoryGB * 1GB) + Set-VMProcessor -VMName $vm.Name -Count $processorCount +} +elseif ($vm.MemoryStartup -ne ($memoryGB * 1GB) -or $vm.ProcessorCount -ne $processorCount) { + Write-Warning "The guest is $($vm.State); the $ResourceProfile profile will apply after the next stop." +} + +if ($vm.State -ne 'Running') { + Start-VM -Name $vm.Name +} + +if ($Wait) { + if (-not (Test-Path $CredentialPath -PathType Leaf)) { + throw "DPAPI credential file was not found: $CredentialPath" + } + $credential = Import-Clixml $CredentialPath + $deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes) + $session = $null + do { + try { + $session = New-PSSession -VMName $vm.Name -Credential $credential -ErrorAction Stop + break + } + catch { + if ([DateTime]::UtcNow -ge $deadline) { + throw "PowerShell Direct did not answer for '$($vm.Name)' within $TimeoutMinutes minute(s). The guest and its disk are preserved; watch the console with vmconnect.exe localhost $($vm.Name)." + } + Start-Sleep -Seconds 5 + } + } while ($true) + + try { + $standardUser = [string]$configuration.StandardUser + $desktopDeadline = [DateTime]::UtcNow.AddMinutes(2) + do { + $desktopReady = Invoke-Command -Session $session -ScriptBlock { + param($User) + @(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.UserName -like "*\$User" }).Count -gt 0 + } -ArgumentList $standardUser + if ($desktopReady -or [DateTime]::UtcNow -ge $desktopDeadline) { break } + Start-Sleep -Seconds 5 + } while ($true) + + if (-not $desktopReady) { + Write-Warning "No interactive Explorer session exists for '$standardUser'; repairing auto-logon and restarting once." + $autoLogonScript = Join-Path $PSScriptRoot 'oem\Set-UiTestAutoLogon.ps1' + if (-not (Test-Path $autoLogonScript -PathType Leaf)) { + $autoLogonScript = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\oem\Set-UiTestAutoLogon.ps1')) + } + if (-not (Test-Path $autoLogonScript -PathType Leaf)) { + throw "Auto-logon repair helper was not found: $autoLogonScript" + } + Invoke-Command -Session $session -FilePath $autoLogonScript -ArgumentList $standardUser | Out-Null + if ($null -ne $session) { Remove-PSSession $session -ErrorAction SilentlyContinue } + $session = $null + Restart-VM -Name $vm.Name -Force + + do { + try { + if ($null -eq $session) { + $session = New-PSSession -VMName $vm.Name -Credential $credential -ErrorAction Stop + } + $desktopReady = Invoke-Command -Session $session -ScriptBlock { + param($User) + @(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.UserName -like "*\$User" }).Count -gt 0 + } -ArgumentList $standardUser + if ($desktopReady) { break } + } + catch { + if ($null -ne $session) { Remove-PSSession $session -ErrorAction SilentlyContinue } + $session = $null + } + + if ([DateTime]::UtcNow -ge $deadline) { + throw "The '$standardUser' interactive desktop did not appear after auto-logon repair." + } + Start-Sleep -Seconds 5 + } while ($true) + } + } + finally { + if ($null -ne $session) { Remove-PSSession $session -ErrorAction SilentlyContinue } + } +} + +$vm = Get-VM -Name $configuration.VmName +[pscustomobject]@{ + VmName = $vm.Name + State = [string]$vm.State + ResourceProfile = $ResourceProfile + MemoryStartupGB = [math]::Round($vm.MemoryStartup / 1GB, 1) + ProcessorCount = $vm.ProcessorCount + Uptime = [string]$vm.Uptime + Checkpoints = @(Get-VMCheckpoint -VMName $vm.Name -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + Console = "vmconnect.exe localhost `"$($vm.Name)`"" + ControlChannel = "vmbus://$($vm.Name)" +} | ConvertTo-Json diff --git a/.github/skills/ui-tests-local-vm/templates/vm/Stop-LocalVm.ps1 b/.github/skills/ui-tests-local-vm/templates/vm/Stop-LocalVm.ps1 new file mode 100644 index 000000000000..6756dcfd3933 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/Stop-LocalVm.ps1 @@ -0,0 +1,84 @@ +# 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. + +<# +.SYNOPSIS +Stops the persistent Hyper-V UI-test guest while preserving its disk and checkpoints. + +.EXAMPLE +pwsh ./Stop-LocalVm.ps1 +#> + +[CmdletBinding()] +param( + [string]$ConfigPath = (Join-Path $PSScriptRoot 'vm.config.psd1'), + [string]$CredentialPath = (Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml'), + # Saves the running state instead of shutting the guest down, so the next start resumes instantly. + [switch]$Save, + [switch]$TurnOff +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $ConfigPath -PathType Leaf)) { + throw "Configuration was not found: $ConfigPath" +} +$configuration = Import-PowerShellDataFile $ConfigPath + +try { + Import-Module Hyper-V -ErrorAction Stop + Get-VMHost -ErrorAction Stop | Out-Null +} +catch { + throw 'BLOCKED: Hyper-V is not accessible from this shell. Run from an elevated PowerShell 7 terminal, or add this account to the local "Hyper-V Administrators" group.' +} + +$vm = Get-VM -Name $configuration.VmName -ErrorAction SilentlyContinue +if ($null -eq $vm) { + throw "Virtual machine '$($configuration.VmName)' does not exist." +} + +if ($vm.State -eq 'Running') { + if (-not $Save -and -not $TurnOff) { + if (-not (Test-Path $CredentialPath -PathType Leaf)) { + throw "DPAPI credential file was not found: $CredentialPath" + } + + $credential = Import-Clixml $CredentialPath + $session = New-PSSession -VMName $vm.Name -Credential $credential + try { + # Validate the protected guest-local credential; repair it only when it no longer authenticates. + $autoLogonScript = Join-Path $PSScriptRoot 'oem\Set-UiTestAutoLogon.ps1' + if (-not (Test-Path $autoLogonScript -PathType Leaf)) { + $autoLogonScript = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\oem\Set-UiTestAutoLogon.ps1')) + } + if (-not (Test-Path $autoLogonScript -PathType Leaf)) { + throw "Auto-logon persistence helper was not found: $autoLogonScript" + } + Invoke-Command ` + -Session $session ` + -FilePath $autoLogonScript ` + -ArgumentList ([string]$configuration.StandardUser) | Out-Null + } + finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + } + } + + if ($Save) { + Save-VM -Name $vm.Name + } + elseif ($TurnOff) { + Stop-VM -Name $vm.Name -TurnOff -Force + } + else { + Stop-VM -Name $vm.Name -Force + } +} + +$vm = Get-VM -Name $configuration.VmName +[pscustomobject]@{ + VmName = $vm.Name + State = [string]$vm.State +} | ConvertTo-Json diff --git a/.github/skills/ui-tests-local-vm/templates/vm/unattend/autounattend.xml.template b/.github/skills/ui-tests-local-vm/templates/vm/unattend/autounattend.xml.template new file mode 100644 index 000000000000..3d549507e2b4 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/unattend/autounattend.xml.template @@ -0,0 +1,184 @@ + + + + + + + {{LOCALE}} + + {{LOCALE}} + {{LOCALE}} + {{LOCALE}} + {{LOCALE}} + + + + true + Never + + + true + + + + + + OnError + + 0 + true + + + 1 + EFI + 300 + + + 2 + MSR + 128 + + + 3 + Primary + true + + + + + 1 + 1 + + FAT32 + + + 2 + 2 + + + 3 + 3 + + C + NTFS + + + + + + + + + /IMAGE/NAME + {{IMAGENAME}} + + + + 0 + 3 + + + + + + + + + {{COMPUTERNAME}} + {{TIMEZONE}} + + + true + + + + + 1 + Bypass the online account requirement + reg add "HKLM\Software\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f + + + + + + + + {{LOCALE}} + {{LOCALE}} + {{LOCALE}} + {{LOCALE}} + + + true + + + 1 + + + + true + true + true + true + true + 3 + + + + + {{ADMINUSER}} + {{ADMINUSER}} + Administrators + Local UI-test VM control account + + {{ADMINPASSWORD}} + false</PlainText> + </Password> + </LocalAccount> + </LocalAccounts> + </UserAccounts> + <AutoLogon> + <Enabled>true</Enabled> + <LogonCount>999</LogonCount> + <Username>{{ADMINUSER}}</Username> + <Password> + <Value>{{ADMINPASSWORD}}</Value> + <PlainText>false</PlainText> + </Password> + </AutoLogon> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <Description>Copy the OEM payload from the answer media</Description> + <CommandLine>cmd.exe /c for %i in (D E F G H I J) do @if exist %i:\OEM\Provision-UiTestVm.ps1 xcopy /e /i /y %i:\OEM C:\OEM</CommandLine> + </SynchronousCommand> + <SynchronousCommand wcm:action="add"> + <Order>2</Order> + <Description>Provision the UI-test guest</Description> + <CommandLine>powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\OEM\Provision-UiTestVm.ps1 {{PROVISIONARGUMENTS}}</CommandLine> + </SynchronousCommand> + <SynchronousCommand wcm:action="add"> + <Order>3</Order> + <Description>Restart into the standard-user desktop</Description> + <CommandLine>shutdown.exe /r /t 5 /c "PowerToys UI-test provisioning complete"</CommandLine> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> +</unattend> diff --git a/.github/skills/ui-tests-local-vm/templates/vm/vm.config.example.psd1 b/.github/skills/ui-tests-local-vm/templates/vm/vm.config.example.psd1 new file mode 100644 index 000000000000..5580c920d492 --- /dev/null +++ b/.github/skills/ui-tests-local-vm/templates/vm/vm.config.example.psd1 @@ -0,0 +1,37 @@ +@{ + # Copy this file to vm.config.psd1 in the same folder. Never commit vm.config.psd1. + # The administrator password is never stored here: it is imported from the DPAPI credential file. + + VmName = 'PowerToysUiTest-Win11' + ComputerName = 'PTUITEST' + + # Guest storage. Keep it outside the repository. + # Prefer NTFS. Keeping a VHDX on a Dev Drive has been observed to wedge the Hyper-V management + # service until vmms is restarted or the host is rebooted. Hyper-V on plain ReFS is supported, so + # New-UiTestVm.ps1 refuses ReFS only as a conservative proxy for Dev Drive; pass + # -AllowReFsVolume to override. The scaffold and shared exchange have no such restriction. + VmPath = 'D:\PowerToysUiTestVm\vm' + VhdPath = 'D:\PowerToysUiTestVm\vm\PowerToysUiTest.vhdx' + DiskSizeGB = 128 + + # Default resource profile. Lower to 1 vCPU / 4 GB only after the suite is green. + MemoryStartupGB = 8 + ProcessorCount = 4 + ConstrainedMemoryStartupGB = 4 + ConstrainedProcessorCount = 1 + + # Set to '' for a fully isolated guest. PowerShell Direct works without any network adapter. + SwitchName = 'Default Switch' + + # Accounts. The password comes from the DPAPI credential file, not from this file. + AdminUserName = 'PTAdmin' + StandardUser = 'PTUser' + + # arm64 or amd64. Must match the installation media and the guest payloads. + ProcessorArchitecture = 'amd64' + Locale = 'en-US' + TimeZone = 'UTC' + + # Standard checkpoints capture memory, so restoring returns to a logged-on desktop immediately. + BaselineCheckpointName = 'provisioned-baseline' +} diff --git a/.github/skills/ui-tests-migration/SKILL.md b/.github/skills/ui-tests-migration/SKILL.md index 632c8007d3ee..73415022e9b7 100644 --- a/.github/skills/ui-tests-migration/SKILL.md +++ b/.github/skills/ui-tests-migration/SKILL.md @@ -1,6 +1,6 @@ --- name: ui-tests-migration -description: "Migrate PowerToys module UI tests from the legacy WinAppDriver/Selenium harness (Microsoft.PowerToys.UITest) to the new winappcli-based harness (Microsoft.PowerToys.UITest.Next). Use when asked to port/convert/rewrite/modernize a module's UI tests to the .Next framework, create a new [Module].UITests.Next project alongside existing legacy tests, or stand up brand-new winappcli UI tests for a module that has none by reading its human test sign-off markdown. Covers the API mapping (By/Element/Session/UITestBase, KeyboardHelper/MouseHelper/ClipboardHelper), project/csproj scaffolding, naming rules, common PowerToys test recipes (toggle a module, read an activation shortcut, fire a global hotkey, inspect the clipboard, discover overlay/editor windows), build/run validation, and CI-stability hardening for fewer CI iterations. Keywords: UI test, UITests, UITestAutomation.Next, winappcli, WinAppDriver, Selenium, migrate, port, modernize, .Next, MSTest, CI stability, flaky test, stabilize on CI." +description: "Migrate and stabilize PowerToys UI tests from WinAppDriver/Selenium to Microsoft.PowerToys.UITest.Next and winappcli. Use for ports, new UITest projects, flaky CI tests, persistent local-VM validation on Hyper-V, resettable clean-baseline runs, Explorer/Shell selection, preview handlers, thumbnail providers, hotkey activation, stateful process lifecycle, composed WinUI/WebView visual baselines, or cross-window/foreground failures. Covers APIs, scaffolding, test design, diagnostics, agentic execution, and CI hardening. Keywords: UI test, UITests, UITestAutomation.Next, winappcli, WinAppDriver, Selenium, local VM, Hyper-V, checkpoint, migrate, port, flaky, CI stability, Explorer, Shell extension, WebView2." license: Complete terms in LICENSE.txt --- @@ -27,6 +27,9 @@ Use this skill when the task is to: - **Stand up brand-new** `.Next` UI tests for a module that has **no** UI tests at all, by reading the module's human test **sign-off markdown** (e.g. `ColorPickerUITest.md`) and turning each manual checklist item into an automated test. +- **Validate a new or migrated suite in a local Windows VM** through an unattended + build/package/deploy/run/TRX/diagnose loop. Use a retained VM for fast iteration and a restored + baseline checkpoint when clean-profile behavior matters. This skill is the *how*: the framework differences, the API mapping, the project scaffolding, the naming rules, the recurring PowerToys test recipes, and the build/validate loop. The *what* (which @@ -48,6 +51,16 @@ module, which tests) comes from the calling prompt. > `Session.FromProcess`, a DPI-aware `app.manifest`, cursor centering, and patient hotkey > activation are all there because real runs needed them (see > [references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md)). +> - **Stateful/visual reference (validated 15/15 across Win10 x64, Win11 x64, and ARM64)**: +> [PeekFilePreviewTests.cs](../../../src/modules/peek/Peek.UITests.Next/PeekFilePreviewTests.cs) +> demonstrates stable Explorer Shell selection, toggle-hotkey activation, process-preserving +> pinning tests, renderer readiness, and composed WinUI/WebView visual baselines. +> - **Explorer/Shell-extension reference (validated across x64 and ARM64 CI)**: +> [FileExplorerAddonsTests.cs](../../../src/modules/previewpane/PreviewPane.UITests/FileExplorerAddonsTests.cs) +> demonstrates class-scoped runner reuse, one-time Shell restart, state-aware Preview pane +> activation, exact Shell selection, deterministic icon sizes, provider-log readiness, and +> failure media captured before Explorer teardown. Read +> [references/explorer-shell-tests.md](references/explorer-shell-tests.md) before testing Explorer. ## Required reads (in order) @@ -70,12 +83,18 @@ module, which tests) comes from the calling prompt. for the recurring PowerToys patterns (toggle a module + verify its process, read the activation shortcut from a `ShortcutControl`, fire a global hotkey reliably, inspect the clipboard, discover overlay/editor windows) and the gotchas that bite during migration. -7. **[references/ci-stability.md](references/ci-stability.md)** — the CI-stability capstone: the - Win32-window vs UIA-element mental model, five design principles that keep a port green on a slow - CI agent (authoritative-signal retries over fixed sleeps, invoke-vs-physical-click, screen-capture - cold-start, toggle-state guards, on-screen/DPI/clean-profile hygiene), and a **pre-flight - checklist** to apply BEFORE the first CI push so the first run *validates* instead of *discovers*. - Read this to spend one CI iteration instead of six. +7. **[references/explorer-shell-tests.md](references/explorer-shell-tests.md)** — required for tests + involving Explorer, preview handlers, thumbnail providers, Shell selection, view modes, or Shell + restarts. Covers lifecycle boundaries, authoritative signals, and failure evidence. +8. **[references/ci-stability.md](references/ci-stability.md)** — the CI-stability capstone: the + Win32-window vs UIA-element mental model, state-boundary worksheet, stable-sample waits, retry + semantics, foreground/integrity constraints, process lifecycle, composed visual capture, and a + **pre-flight checklist** to apply BEFORE the first CI push so the first run *validates* instead of + *discovers*. Read this to spend one CI iteration instead of six. +9. **[ui-tests-local-vm](../ui-tests-local-vm/SKILL.md)** — the live desktop execution loop: + scaffold or reuse a persistent Hyper-V VM, run as a true standard user, refresh only + changed payloads, iterate through durable TRX/evidence, and restore or recreate the baseline for + clean-profile validation. ## Pick your scenario @@ -110,23 +129,37 @@ Create a TODO list and work top-to-bottom. Each step links to the reference that ```markdown - [ ] 1. Identify the module + scenario (A port / B greenfield) — this SKILL.md "Pick your scenario" +- [ ] 1a. Read the module's developer docs — `doc/devdocs/modules/<module>.md` (if the exact file is + missing, search `doc/devdocs/`, including `doc/devdocs/common/`) — to learn its + development-cycle specifics BEFORE writing tests: how its shell extensions / context menus + register, whether they need a **Release** build (`NDEBUG`) or a **signed** sparse MSIX package, + and any Explorer-restart or first-run needs. Skipping this produces opaque failures — e.g. a + context-menu entry never appears because a Debug build compiles registration out, or an + unsigned `.msix` fails to register (`0x800B0100`). - [ ] 2. Read the two reference examples (ColorPicker .Next + ScreenRuler legacy) end-to-end - [ ] 3. Inventory the source: • Scenario A → list every [TestMethod] + shared helper in the legacy project • Scenario B → read the module's sign-off .md; list each manual checklist item + • For each workflow → list every external boundary (runner, Explorer, HWND, renderer, + compositor, child process) and its authoritative ready signal — references/porting-workflow.md - [ ] 4. Internalize the deltas — references/framework-differences.md - [ ] 5. Scaffold the new project (csproj from template, name per the table, register in .slnx) — references/project-setup.md - [ ] 6. Re-implement tests, mapping each API as you go — references/api-mapping.md + recipes from references/patterns-and-pitfalls.md +- [ ] 6a. If Explorer/Shell is involved, apply references/explorer-shell-tests.md - [ ] 7. Apply the CI-stability checklist BEFORE building — references/ci-stability.md - (authoritative-signal retries not fixed sleeps, navigation via UIA invoke, Win32 window/overlay - detection, screen-capture cold-start handling, DPI manifest, single-module enable, first-run - suppression) + (stable authoritative signals, retry classification, foreground/integrity, lifecycle reset + scope, non-activating helper processes, composed capture, DPI manifest, single-module enable, + first-run suppression) - [ ] 8. Build the new project to exit code 0 — this SKILL.md "Build & validate" -- [ ] 9. (If a live desktop is available) run the tests; otherwise report that they build and are - ready to run, and summarize coverage vs. the source +- [ ] 9. Run one deterministic test in the local VM and diagnose the first failure + — ../ui-tests-local-vm/SKILL.md +- [ ] 10. Rerun the focused test after each fix, then widen to the complete module suite with bounded + timeouts; parse TRX and verify durable evidence export +- [ ] 11. If the local VM is unavailable or unsupported, run on another live desktop or report the exact + environmental blocker; do not silently stop at compile validation ``` ## Build & validate @@ -145,6 +178,9 @@ dotnet restore src\modules\<Module>\Tests\<Module>.UITests.Next\<Module>.UITests tools\build\build.cmd -Path src\modules\<Module>\Tests\<Module>.UITests.Next -Platform x64 -Configuration Debug # Exit code 0 = success; non-zero = failure. On failure read the errors log next to the project: # build.<Configuration>.<Platform>.errors.log +# Do not substitute `dotnet build` when UITestAutomation.Next's COM references are in the graph: +# .NET SDK MSBuild cannot run ResolveComReference and fails with MSB4803. Use the repo script or +# Visual Studio's full-framework MSBuild.exe; use `dotnet restore` only to create project.assets.json. # 2. Run (needs a live desktop). A .Next project is a Microsoft.Testing.Platform Exe — run the # produced exe directly with a TRX report; filter to one test/category for a tight loop. @@ -154,15 +190,30 @@ $exe = "<repo>\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0 # Exit 0 = all passed. Parse the .trx for per-test outcomes + failure messages. ``` +- **Default to persistent local VM validation — + [ui-tests-local-vm](../ui-tests-local-vm/SKILL.md).** It keeps the interactive desktop and staged + tools, refreshes only changed archives, and returns durable status/TRX/evidence. Do not + modify stabilized tests merely to improve a VM-specific pass rate when the task only asks whether + the execution loop works. Finish clean-profile claims from a restored known baseline or a fresh + named VM volume. - **Design for CI stability up-front — [references/ci-stability.md](references/ci-stability.md).** Before the first push, walk its pre-flight checklist (authoritative-signal retries instead of fixed - sleeps, navigation via UIA invoke, Win32 window/overlay detection, screen-capture cold-start - handling, DPI manifest, single-module enable, first-run suppression). Most "passes local, fails CI" - loops come from skipping one of these; applying them proactively is how you spend one CI iteration - instead of six. + sleeps, navigation via UIA invoke, interaction-scoped foreground checks, non-activating helpers, + Win32 window/overlay detection, screen-capture cold-start handling, DPI manifest, single-module + enable, first-run suppression). Most "passes local, fails CI" loops come from skipping one of + these; applying them proactively is how you spend one CI iteration instead of six. - **Run it in a loop: write → build → run → diagnose → repeat.** UI tests surface environment-real failures (DPI scaling, cursor position, hotkey-arming races) that only a live run reveals. Start with one deterministic test (e.g. the activation/toggle test), get it green, then widen. +- **Diagnose from the artifacts, not from the assertion message.** Every failed test attaches a + desktop screenshot, and in pipeline mode an MP4 of the run. **Open them before forming any theory** + — especially before concluding the product is broken. An assertion can only say "found 0 rows"; the + screenshot says whether the list was empty or whether your selector was wrong. This is the single + highest-leverage habit in the agentic loop: skipping it cost ~8 iterations and a confident but + entirely wrong product-defect report on File Locksmith (see + [references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md) Pitfall 26). If there is + no video, find out why rather than proceeding blind — the harness now prints the reason (a clean + Windows image without the Visual C++ redistributable cannot load the native encoder). - **First, run the *legacy* suite once for a baseline — and run it ELEVATED.** The legacy harness launches PowerToys via `ProcessStartInfo { Verb = "runas" }` (elevated), so a **non-elevated** test host can't complete the launch and **every test fails at startup with a misleading `Win32Exception` @@ -188,10 +239,14 @@ $exe = "<repo>\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0 - **Do NOT delete or edit the legacy `[Module].UITests` project** in Scenario A. The `.Next` project lives alongside it; removing the old one is a separate, explicit decision for the maintainers. -- **Do NOT touch product code.** This is a test-only migration. If a test needs a UIA hook that - doesn't exist (e.g. an `AutomationId` or a hidden automation-peer TextBlock), flag it for the user - rather than silently editing the module. (The ColorPicker example's `ColorHexAutomationPeer` hook - is a documented, pre-existing exception — see its class remarks.) +- **Do NOT change product behaviour.** This is a test-only migration. The one sanctioned product edit + is adding **`AutomationProperties.AutomationId`** to a control that is otherwise unaddressable — an + icon-only button whose label lives in a tooltip has no UIA Name at all, and the alternative is a + brittle coordinate click. Use `AutomationProperties.AutomationId`, never `x:Name` (which also emits + a code-behind field); see [references/patterns-and-pitfalls.md](references/patterns-and-pitfalls.md) + Recipe 16. Anything larger — a hidden automation-peer TextBlock, a new property, a state string — + must be flagged for the user instead. (ColorPicker's `ColorHexAutomationPeer` hook is a documented, + pre-existing exception — see its class remarks.) - **Do NOT port the legacy plumbing literally.** No Selenium `Actions`, no `WindowsDriver`/`WindowsElement`, no `By.XPath`/`By.CssSelector`, no `:4723`. Map them to the winappcli idioms in [references/api-mapping.md](references/api-mapping.md). @@ -202,7 +257,13 @@ $exe = "<repo>\x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0 (or skip with an explanation) rather than asserting on something you can't actually read. - **Do NOT introduce new third-party NuGet dependencies.** The `.Next` harness is intentionally dependency-free (MSTest only). Use the Win32-based helpers it already ships. +- **Do NOT retry a toggle hotkey blindly.** Once any target window appears, wait for initialization; + resending the chord may close a healthy window. Restart only after a terminal readiness failure. +- **Do NOT replace or weaken visual baselines before proving capture is correct.** Foreground HWND, + DWM z-order, composed WebView content, theme, and platform are separate failure sources. ## What is NICE to do -- **Improve the new UT Test framework if you see such opportunity**. The new framework works only with a few modules and may lack something other requires. If you see the old test uses something that we don't have in a new framework and it's handy, don't hesiate to port it to a new one. Or you may see the test uses a bunch of extra helpers ouside of test framework, which also may be a signal. \ No newline at end of file +- **Improve the framework when a helper is demonstrably reusable.** Prefer small composable APIs + (`WaitHelper`, `WindowControl`, `ExplorerShell`) over module-specific mega-helpers. Keep product + semantics such as Peek pin-state preservation in the module test. diff --git a/.github/skills/ui-tests-migration/references/api-mapping.md b/.github/skills/ui-tests-migration/references/api-mapping.md index 41b61dfbb559..ceb707d16005 100644 --- a/.github/skills/ui-tests-migration/references/api-mapping.md +++ b/.github/skills/ui-tests-migration/references/api-mapping.md @@ -32,6 +32,8 @@ is the `Microsoft.PowerToys.UITest.Next` equivalent. "—" means no direct membe | `IsWindowOpen(name)` | `WindowsFinder.ListByApp(proc).Count > 0` | Or `SessionHelper.IsRunning(scope)` for a process check. | | `RestartScopeExe(enableModules?)` | `RestartScope(enableModules?)` | Returns the fresh `Session`. | | `ExitScopeExe()` | *(automatic)* `sessionHelper.StopIfStarted()` in `TestCleanup` | Rarely needed manually. | +| class-level manual launch/cache | `protected override bool ReuseScopeAcrossTests => true` | Keeps the runner/scope alive across a test class; each test still rebinds its `Session`. | +| capture before custom cleanup | `CaptureFailureArtifactsBeforeCleanupAsync(tail?)` | Call first in derived `[TestCleanup]` before closing diagnostic windows. No-op for passing tests. | ## `PowerToysModule` enum (values differ!) @@ -111,6 +113,7 @@ is the `Microsoft.PowerToys.UITest.Next` equivalent. "—" means no direct membe | — | `Inspect(depth, interactive, …)` → `JsonElement` | `winapp ui inspect --json` tree (the ColorPicker editor walk). | | — | `WaitForElement(by, t)`, `WaitFor(Func<bool>, t)` | Built-in waits. | | — | `Screenshot(path, element?, captureScreen?)` / `TryScreenshot(...)` | | +| — | `ScreenshotVisibleWindow(path)` | Captures composed WinUI/WebView content from DWM-visible desktop pixels; requires a window-scoped foreground session. | ### `MouseActionType` → `MouseHelper` @@ -135,6 +138,12 @@ is the `Microsoft.PowerToys.UITest.Next` equivalent. "—" means no direct membe | Clear clipboard | `ClipboardHelper.Clear()` | | Set clipboard | `ClipboardHelper.SetText("v")` | | Wait for clipboard to change | `ClipboardHelper.WaitForText(ignoredValue, timeoutMS)` | +| Wait for consecutive stable observations | `WaitHelper.WaitForStable(observe, isMatch, timeoutMS, requiredConsecutiveMatches, …)` | +| Wait for exact HWND foreground | `WindowControl.WaitForForeground(hwnd, timeoutMS, stableSamples)` | +| Diagnose current foreground owner | `WindowControl.GetForegroundWindowInfo()` | +| Stop an exact process tree and await exit | `WindowControl.TryKillProcessTreeByNameAndWait(name, timeoutMS)` | +| Set/read exact Explorer Shell selection | `ExplorerShell.SetSelectionAndWaitForStable(...)` / `TryGetSelection(hwnd)` | +| Set/read Explorer view mode + icon size | `ExplorerShell.SetViewModeAndIconSizeAndWait(hwnd, ViewMode.Icons, iconSize)` | Uses Shell automation, not a timing-sensitive keyboard shortcut. | | Seed module on/off baseline | `SettingsConfigHelper.ConfigureGlobalModuleSettings("ColorPicker", …)` | | Edit a module's own settings.json | `SettingsConfigHelper.UpdateModuleSettings(name, default, json => {…})` | diff --git a/.github/skills/ui-tests-migration/references/ci-stability.md b/.github/skills/ui-tests-migration/references/ci-stability.md index b762704c75dd..6f28879a6e79 100644 --- a/.github/skills/ui-tests-migration/references/ci-stability.md +++ b/.github/skills/ui-tests-migration/references/ci-stability.md @@ -11,7 +11,7 @@ and pitfalls by number). The canonical worked example for everything below is th [ScreenRuler.UITests.Next/TestHelper.cs](../../../../src/modules/MeasureTool/Tests/ScreenRuler.UITests.Next/TestHelper.cs). > **Why this matters for iteration count.** Almost every "flaky on CI, fine locally" failure traces to -> one of five root causes below. A dev box hides all of them (higher-res display, warmed caches, a +> one of the root causes below. A dev box hides them (higher-res display, warmed caches, a > profile that already dismissed first-run windows, a human not touching the mouse). If you design for > them up-front, the first CI run tends to be green; if you don't, you rediscover them one push at a > time. @@ -43,6 +43,24 @@ The operating rules that fall out of this: empties the very next frame. For a capture module, detect windows with Win32 `EnumWindows`, not UIA (Pitfall 18). +### Model the workflow as owned state boundaries + +Before writing retries, make a table for every external boundary. Peek's stable workflow was: +`Explorer HWND → Shell selection/focus → hotkey → Peek HWND/title → renderer state → DWM pixels`. + +| Boundary | Owner | Authoritative signal | Stability / recovery | +|---|---|---|---| +| Top-level window | Win32 | Exact expected HWND exists and owns foreground | Retry foreground; diagnose foreground PID/title/elevation | +| Explorer selection | Shell view | Exact selected path set plus focused path | Require consecutive samples; repair through `ExplorerShell` | +| Explorer layout | Shell view | Exact view mode and icon size | Set through `ExplorerShell`; verify item geometry | +| Shell extension | Explorer + provider | Provider log plus visible content | Drive through Explorer in the user's context; avoid test-host-only COM probes | +| Toggle hotkey | Runner/module | Any target HWND appeared | Stop resending once a window exists; wait for initialization | +| Renderer | Product automation peer | Product state is `Loaded`; loading UI is gone | Restart the process tree only after a bounded terminal failure | +| Visible output | DWM/compositor | Captured pixels match baseline | Capture composed desktop pixels; do not rewrite baselines first | + +For each boundary, name: **action, owner, signal, stable-sample count, retry semantics, and reset +scope**. A wait without these fields usually becomes an arbitrary sleep or a destructive retry. + --- ## Principle 1 — Assert on an **authoritative signal**, retry until true (not a fixed sleep) @@ -66,6 +84,22 @@ the clipboard is empty. Both adapt to a slow agent for free. > Corollary: **fail with the signal in the message.** `Assert.Fail("overlay never appeared after N > attempts")` tells you *which* signal missed on CI; `Assert.IsTrue(x)` tells you nothing. +### Observed once is not necessarily stable + +Explorer selection, foreground, window bounds, and renderer state can briefly match and then regress +while deferred UI initializes. Use `WaitHelper.WaitForStable` when readiness must survive several +samples. A mismatch resets the count and may run a recovery action. Keep the observation structured +so timeout diagnostics can report the last state rather than only `false`. + +Classify every retry before implementing it: + +- **Idempotent**: setting text, selecting an exact Shell item, bringing a known HWND forward. Safe to + repeat while the signal is false. +- **Toggle**: activation hotkeys, pin buttons, toggle buttons. Re-read state before repeating; once + any target window appears, do not resend a show/hide chord. +- **Destructive/resetting**: killing a process, recreating WebView2, restarting capture. Use only after + patient in-place readiness has failed, because the reset discards progress and state. + --- ## Principle 2 — The input-method decision: UIA **invoke** vs physical **click** @@ -101,16 +135,33 @@ window holds the foreground, Windows' **foreground lock** puts it *behind* that and looks **exactly** like the interactivity race, but it's occlusion. This is a prime "passes local, flakes on CI" cause: on CI the Settings window used to enable the module is still foreground when the overlay appears. The harness guards against it — `Element.Click()` calls `Session.EnsureForeground()` -first, which raises the target with the foreground-lock-defeating `AttachThreadInput` dance -(`WindowControl.TryBringToForeground`) before the real click, and still falls back to `Invoke()` if the -raise doesn't take. Diagnose it via winappcli's `isForeground` flag on `list-windows`; UIA `invoke` is -immune because it never touches coordinates. - -**Elevation must match.** Injected input — a synthetic hotkey *or* a real click — from a process at a -*different* integrity level than the PowerToys runner is blocked by UIPI: a non-elevated host can't -drive an elevated runner, and an elevated host's foreground window blocks the non-elevated runner's -hook. Run the test host at the **same** elevation as the runner (the `.Next` harness launches the -runner non-elevated, so run the tests non-elevated too). +first. `WindowControl.TryBringToForeground` is best-effort; use `WaitForForeground` when exact +ownership is required and inspect `GetForegroundWindowInfo()` on failure. UIA `Invoke` is immune to +occlusion because it never touches coordinates. + +**Integrity boundaries can make foreground activation impossible.** `AttachThreadInput` does not +override UIPI. A visible elevated helper console can permanently block a non-elevated Explorer or +module window. Pipeline helpers must start hidden (the shared WinAppDriver uses `-WindowStyle Hidden`). +Log the foreground process, title, and elevation before adding more retries. Match the test host and +runner integrity where possible; modules configured to run non-elevated still require their own +foreground handoff. + +"Start hidden" means **hidden at process creation**, not enumerate-and-hide afterward. The latter is +a time-of-check/time-of-use race: a shell-launched console can be created after the hide pass and own +foreground just as the target opens. For same-integrity direct children, use `UseShellExecute=false` +plus `CreateNoWindow=true`. If an elevated host must ask Explorer to create a medium-integrity helper, +do not route it through `.cmd`/`start /b`; use a non-activating launcher such as +`WScript.Shell.Run(..., 0, False)` with an encoded command. Smoke-test the launcher independently: +the helper must establish its readiness precondition while exposing no main window and never becoming +the foreground PID. + +**Require foreground only when the interaction requires it.** Explorer context menus, SendInput, +coordinate clicks, and drags need stable ownership because focus or z-order changes the operation. +Coordinate-free UIA search/invoke does not. For those flows, an exact-HWND assertion can be a false +negative when WinUI recreates its top-level window or the scheduled interactive host observes +`GetForegroundWindow()==0`; use process/window presence plus the authoritative UIA-ready element, +while keeping foreground activation best-effort and diagnostic. Let the interaction boundary decide — +do not globally weaken strict Explorer or physical-input checks. --- @@ -146,9 +197,23 @@ to select" can *deselect* an already-engaged control — and an innocent retry c guard on `GetProperty("ToggleState")` yourself (as `SelectToolAndVerify` / `ReengageTool` do). This is also why a *retry loop* around a toggle is dangerous unless it re-reads state each pass. +The same state rule applies to global hotkeys that toggle a window. Revalidate the input source, +send the chord once, wait for any target HWND, then wait for expected title/content without +resending. If initialization reaches a terminal timeout, stop the full process tree and begin a +fresh attempt. + +--- + +## Principle 5 — Match process lifecycle to scenario state + +Closing a window, waiting for input idle, and terminating a process tree are different operations. +Some state lives only in a long-running process. Peek's pinned geometry must preserve that process, +while an explicitly unpinned reopen is safer with a fresh process. Encode this as a lifecycle matrix +per scenario; use `TryKillProcessTreeByNameAndWait` only where state should be discarded. + --- -## Principle 5 — Everything on-screen, DPI-correct, from a clean profile +## Principle 6 — Everything on-screen, DPI-correct, from a clean profile The whole "passes local, fails CI" cluster is environment differences a dev box papers over. Each has a one-time fix; do them all up-front: @@ -163,19 +228,80 @@ a one-time fix; do them all up-front: --- +## Principle 7 — Trace every action with a timestamp so a hang shows where it stuck + +An assertion failure gives you a stack trace; a **hang or CI timeout does not** — the process is +killed with no exception and the recording only shows a frozen window, so you cannot tell *which* step +blocked. Emit a **timestamped line before every meaningful UI action**: on CI the **last line before +the kill** names the stuck step, and the gap between two lines shows *which* step was slow (a classic +context menu that took 15 s is obvious from the timestamps, with no profiler). + +```csharp +// Last line before a CI timeout = the step that hung; gaps between lines = the slow step. +private void Step(string message) => + TestContext.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}"); +``` + +```csharp +Step($"Opening Explorer at '{folder}'"); +var explorer = OpenExplorer(folder); +Step("Selecting fixture"); +SelectFiles(explorer, fixture); +Step("Opening context menu"); +var menu = OpenContextMenu(explorer); // if this blocks, the trail ends on this line +Step($"Invoking '{ContextMenuCaption}'"); +``` + +- **Log *before* the blocking call, not after** — a line printed after the action can never appear for + the action that hung. +- **`TestContext.WriteLine`, not `Console.WriteLine`** — MTP captures it into the TRX + `<Output><StdOut>` attributed to the test, so it reaches the CI test log and the failure attachment. +- **UTC, millisecond precision** — read per-step durations straight from adjacent lines. +- **Name the target** (file, window, control, awaited signal), not just the verb. +- **One line per external interaction** (open / select / menu / invoke / wait-for-window), so the trail + reads as the workflow, not noise. + +This complements Principle 1 (a signal-bearing `Assert.Fail` says *what* was missing) and a rich +one-shot failure dump (Peek's `GetActivationDiagnostics`: foreground PID/title/elevation, process and +window inventory): the trail says *where* it stuck, the dump says *why*. + +--- + +## Composed visuals: HWND, renderer, and pixels are separate + +`PrintWindow` can omit WinUI/WebView2/compositor content. Use `Session.ScreenshotVisibleWindow`, which +requires exact foreground ownership and captures DWM extended-frame screen pixels. The capture helper +temporarily raises the target topmost and restores its prior state so another window cannot contaminate +the frame. `VisualAssert` retries pixel comparison because `Loaded` may precede the final composed +frame. Before changing a baseline or similarity threshold, verify title, renderer state, theme, +platform, foreground HWND, z-order, dimensions, and captured content. + +--- + ## Pre-flight CI-stability checklist Tick these **before** the first CI push. Each maps to a principle/recipe above; skipping one is a likely extra CI iteration. ```markdown -- [ ] app.manifest (PerMonitorV2) wired into the csproj — any coordinate-exact test (P5 / Pitfall 12) -- [ ] Base ctor enables ONLY the module under test (P5 / Recipe 9) -- [ ] First-run/what's-new suppression confirmed for capture & coordinate modules (P5 / Pitfall 17) -- [ ] Gestures anchored to ScreenCenter(), cursor moved in steps, never to the current cursor (P5 / Recipe 11) +- [ ] app.manifest (PerMonitorV2) wired into the csproj — any coordinate-exact test (P6 / Pitfall 12) +- [ ] Base ctor enables ONLY the module under test (P6 / Recipe 9) +- [ ] First-run/what's-new suppression confirmed for capture & coordinate modules (P6 / Pitfall 17) +- [ ] Gestures anchored to ScreenCenter(), cursor moved in steps, never to the current cursor (P6 / Recipe 11) - [ ] Navigation & the first interaction go through By.AccessibilityId(...).Click() (invoke under the hood) (P2 / Recipe 1) - [ ] Window/overlay presence via WindowControl/WindowsFinder (Win32) — never a UIA walk of a live-capture window (mental model / P3 / Pitfall 18) - [ ] Every wait polls an authoritative signal to a deadline — no bare Thread.Sleep standing in for "wait until ready" (P1) +- [ ] Multi-part readiness uses consecutive stable samples and reports the last structured observation (P1) +- [ ] Every meaningful UI action is preceded by a timestamped TestContext.WriteLine so a hang/timeout shows the stuck step (P7) +- [ ] Every retry is classified as idempotent, toggle, or destructive; toggle hotkeys stop after any target HWND appears +- [ ] Exact foreground requirements use `WaitForForeground`; failures record foreground PID/title/elevation +- [ ] Pipeline helper processes have no visible foreground-capable windows; detached consoles start hidden +- [ ] Process lifecycle is explicit per scenario: close/preserve/input-idle/process-tree restart +- [ ] Renderer readiness is separate from window/title readiness; composed visuals use visible DWM capture +- [ ] Explorer-driven tests verify exact selected paths and focused path via `ExplorerShell` (Recipe 13) +- [ ] Explorer view mode/icon size is set through `ExplorerShell`, then independently verified by item geometry +- [ ] Shell handlers are activated by Explorer; readiness requires provider logs plus visible output +- [ ] Derived cleanup captures failure artifacts before closing the window that explains the failure - [ ] Capture modules: in-place gesture retry + single re-engage; overlay detected via Win32 (P3 / Recipe 12) - [ ] Toggle/ToggleButton presses guarded on the current ToggleState (P4) - [ ] Clipboard via ClipboardHelper (STA + retry); no hand-rolled STA wrapper (Recipe 5) diff --git a/.github/skills/ui-tests-migration/references/explorer-shell-tests.md b/.github/skills/ui-tests-migration/references/explorer-shell-tests.md new file mode 100644 index 000000000000..257e1e20d4a9 --- /dev/null +++ b/.github/skills/ui-tests-migration/references/explorer-shell-tests.md @@ -0,0 +1,211 @@ +# Explorer and Shell-extension UI tests + +Use this reference for tests that open File Explorer, depend on Shell selection/focus, register a +preview or thumbnail handler, change Explorer's view, or restart the Explorer shell. The canonical +implementation is +[FileExplorerAddonsTests.cs](../../../../src/modules/previewpane/PreviewPane.UITests/FileExplorerAddonsTests.cs). + +## Model three independent lifetimes + +Do not treat "Explorer test" as one process lifecycle: + +| Lifetime | Typical policy | Why | +|---|---|---| +| PowerToys runner + Settings | One launch per test class (`ReuseScopeAcrossTests`) | Keeps module registration alive and avoids repeated cold startup | +| Explorer shell/taskbar | Restart at most once after registration changes | Shell caches associations; repeated restarts are expensive and disruptive | +| Explorer file window | Fresh window per test | Isolates folder, selection, view, and temporary files | + +```csharp +protected override bool ReuseScopeAcrossTests => true; + +[TestInitialize] +public void PrepareTest() => CloseExplorerFileWindows(); + +[TestCleanup] +public async Task CleanupTest() +{ + await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); + CloseExplorerFileWindows(); +} +``` + +The base rebinds a lightweight `Session` on every test but does not relaunch a healthy shared scope. +If that scope dies, the next test relaunches it. The inherited class cleanup stops what the class +launched after the final test. + +## Restart Explorer without killing descendants + +If a Shell restart is required, terminate only `explorer.exe`, not its process tree: + +```csharp +foreach (var process in Process.GetProcessesByName("explorer")) +{ + process.Kill(); + process.WaitForExit(10_000); +} +``` + +`Kill(entireProcessTree: true)` also terminates processes launched from Explorer. In a validation VM +that included `msvsmon`; the apparent remote-debugger "auto stop" was caused by the test itself. +Guard the restart with a class-wide flag so it occurs once. + +## Use the Shell view as the selection authority + +A highlighted UIA row does not prove the selected path set or focused item that Shell extensions +consume. Establish and verify both through `ExplorerShell`: + +```csharp +var selection = ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), + new[] { filePath }, + focusedPath: filePath, + timeoutMS: 30_000, + requiredConsecutiveMatches: 4); + +Assert.IsTrue(selection.Succeeded, + $"Selection did not settle; focus={selection.LastObservation?.FocusedPath ?? "<none>"}."); +``` + +Shell automation may transiently return a null `FolderItem` while a copied file is entering the +view. The framework treats that as not-ready and retries; module tests should not enumerate Shell COM +items themselves. + +## Harden menu- and selection-driving steps for slow agents + +CI agents are far slower than a local VM and ARM64 timing differs from x64, so races that never appear +locally - even on a 1-core guest - surface on CI, and you often cannot reproduce them. Reason from the +failure video/screenshot and make each step self-correcting instead of one-shot. + +**Re-establish the selection before every attempt.** A slow agent re-renders the Explorer view +asynchronously after a module toggles or the shell restarts and drops the selection, so the +right-click targets an unready view and no menu appears. Re-run `SetSelectionAndWaitForStable` inside +the retry loop, not once before it, and reopen a fresh window if it keeps failing: + +```csharp +while (DateTime.UtcNow < deadline) +{ + if (!TrySelectStable(explorer, filePaths)) // non-throwing SetSelectionAndWaitForStable + { + if (++failures >= 2) { explorer = OpenExplorer(folder); failures = 0; } // stale/empty window + continue; + } + + var menu = OpenContextMenu(explorer); + if (menu is not null && HasCommand(menu)) { break; } +} +``` + +**Treat transient popups as retryable.** A menu popup (for example "Show more options") can vanish +between finding it and invoking it, so a raw `Invoke()` throws. Catch it, return null, and let the +caller reopen the menu rather than failing the test. + +**Verify fixtures actually reached disk.** `Bitmap.Save` can lag on a slow/ARM64 agent; a genuinely +empty folder is not a slow-to-render view. Assert `File.Exists` (retry the save once) immediately +after creating a fixture, and prefer committed test assets (Peek's `TestAssets`) over runtime-generated +images when arch-portability matters. + +**Size timeouts for the slow path.** A tier-2 ("Show more options") menu can take ~15s to render under +CI load; use surface waits of >=25s and retry-loop deadlines of >=90s. On a fast agent these return +immediately, so there is no happy-path cost. + +## Set view mode and icon size directly + +Do not rely on `Ctrl+Shift+1/2/3` for thumbnail tests. Under CI load Explorer can drop the shortcut +while remaining foreground and stay in Details view. Set the authoritative Shell state: + +```csharp +var view = ExplorerShell.SetViewModeAndIconSizeAndWait( + new IntPtr(explorer.WindowHandle), + ExplorerShell.ViewMode.Icons, + iconSize: 256, + timeoutMS: 5_000); + +Assert.IsTrue(view.Succeeded, + $"View did not settle; mode={view.LastObservation?.Mode}, size={view.LastObservation?.IconSize}."); +``` + +Still assert on the visible layout. A Shell state match proves the setting, while tile geometry proves +Explorer laid it out. For example, extra-large tiles should be materially taller than a Details row; +then large and medium captures should have strictly descending heights. + +## Drive Shell extensions through Explorer + +Exercise the user workflow, not a shortcut from the test host. A direct +`IShellItemImageFactory.GetImage` call can run under a different integrity/registration context and +return `REGDB_E_CLASSNOTREG` even though non-elevated Explorer can activate the per-user handler. + +### Preview handler sequence + +1. Open a fresh Explorer file window. +2. Detect whether the Preview pane is already visible; its state persists across windows/runs. +3. If absent, foreground Explorer, send `Alt+P`, and require the empty-pane marker to appear. Retry + the guarded toggle only while the pane remains absent. +4. Capture an empty-pane baseline. +5. Set exact Shell selection/focus for the file. +6. Require the PowerToys provider log with no launch failure. +7. Require a visible pixel change in the preview region. + +### Thumbnail provider sequence + +1. Open an empty temporary folder in Explorer. +2. Clear the provider's old log and copy the fixture into the folder. +3. Refresh Explorer and establish exact Shell selection/focus. +4. Set `ViewMode.Icons` and the desired icon size (for example 256 px). +5. Require the provider log before accepting the rendered tile. +6. Capture the file item at each required size; assert non-generic visual detail and descending tile + geometry. + +Do not confuse the Preview pane with thumbnail output. Thumbnail tests assert the file tile/icon in +the main folder view; the Preview pane may remain empty and is irrelevant. + +## Use layered evidence + +One signal is not enough for Shell rendering: + +| Evidence | Proves | +|---|---| +| Effective extension association | Shell points the extension at the expected CLSID | +| Provider process log | PowerToys' handler was actually activated | +| Exact Shell selection/view state | Explorer consumed the intended file in the intended layout | +| Visible pixels/item capture | The user-visible output rendered and is not generic/blank | + +Capture both the empty/before state and rendered/after state for previews. For thumbnails, capture +the item rectangle at each requested size. A passing assertion should survive inspection of those +artifacts by a human. + +## Preserve failure evidence before cleanup + +Derived MSTest cleanup runs before the base cleanup. If it closes Explorer first, the recording ends +on Settings or the desktop and hides the failure. Begin derived cleanup with: + +```csharp +await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); +``` + +The method is a no-op for passing tests, holds failed UI briefly when requested, captures a terminal +desktop PNG, finalizes the recording, and is idempotent with the base cleanup. + +## Failure classification + +| Symptom | Likely boundary | Response | +|---|---|---| +| Highlighted row but wrong file opens | Shell selection/focus | Use `SetSelectionAndWaitForStable`; inspect last snapshot | +| Details row instead of thumbnail tile | Shell view mode/icon size | Use `SetViewModeAndIconSizeAndWait`; verify tile geometry | +| `REGDB_E_CLASSNOTREG` only from test-host COM | Activation context | Remove direct COM probe; let Explorer activate the provider | +| Provider log exists, pixels unchanged | Renderer/compositor | Wait for visible output; attach before/after captures | +| Remote debugger dies during Shell restart | Process-tree teardown | Kill only Explorer, never its descendants | +| Video ends after Explorer disappears | Cleanup ordering | Capture failure artifacts before closing Explorer | +| No context menu after a module toggle on a slow agent | Dropped selection / async view render | Re-select before every attempt; widen surface waits; reopen a stale window | +| Fixture folder is genuinely empty (0 items) | Fixture not flushed to disk | Verify `File.Exists` + retry the save; prefer committed assets | + +## Pre-flight checklist + +- [ ] Runner lifetime, Shell restart count, and file-window lifetime are explicit. +- [ ] Exact selected paths and focused path come from `ExplorerShell`. +- [ ] View mode/icon size comes from `ExplorerShell`, not keyboard shortcuts. +- [ ] Shell extensions are activated by Explorer in the user context. +- [ ] Provider logs and visible output are both asserted. +- [ ] Preview-pane toggles are state-guarded and verified. +- [ ] Explorer restarts kill only Explorer, not the process tree. +- [ ] Failure artifacts are finalized before derived cleanup closes Explorer. +- [ ] Focused scenario passes first, then all scenarios pass on x64 and ARM64 CI-equivalent runs. diff --git a/.github/skills/ui-tests-migration/references/framework-differences.md b/.github/skills/ui-tests-migration/references/framework-differences.md index bd991adffff9..7e0b6143653d 100644 --- a/.github/skills/ui-tests-migration/references/framework-differences.md +++ b/.github/skills/ui-tests-migration/references/framework-differences.md @@ -125,7 +125,14 @@ tests often did by hand: kills + relaunches, reapplies size, returns the fresh `Session`. - **Class-shared window:** override `protected bool ReuseScopeAcrossTests => true;` to launch once per class and reuse the window across `[TestMethod]`s (skips per-test hygiene/relaunch). Use for smoke - suites with many cheap cases against one window. Default is per-test isolation. + suites with many cheap cases against one window, or Shell-extension suites whose registration + belongs to one long-lived runner. Per-test setup may still close/recreate *secondary* windows such + as Explorer file windows. If the shared scope dies, the next test relaunches it. The inherited + class cleanup stops the scope after the final test. Default is per-test isolation. +- **Failure capture vs. derived cleanup:** MSTest runs a derived `[TestCleanup]` before the base + cleanup. If derived cleanup closes the window that explains a failure, call + `CaptureFailureArtifactsBeforeCleanupAsync(...)` first; otherwise the recording's final frame and + desktop screenshot show only the post-cleanup desktop. ## 6. Multi-window discovery diff --git a/.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md b/.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md index a8c805c33dfb..ff1fbb603a34 100644 --- a/.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md +++ b/.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md @@ -315,6 +315,97 @@ string result = MeasureWithRetry(() => { MouseHelper.MoveTo(cx, cy); MouseHelper > Each test spawns its own module process = its own capture session = its own cold-start; there is no > cross-test warming, so every capture test must tolerate the first-frame delay on its own. +## Recipe 13 — Establish exact File Explorer selection + +Do not use UIA child discovery or timing-sensitive `Shift+Arrow` input for Explorer selection. The +Shell view is the authority consumed by Peek and similar file-driven tools: + +```csharp +var result = ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorerWindow.WindowHandle), + selectedPaths: new[] { firstPath, secondPath, focusedPath }, + focusedPath, + timeoutMS: 30_000, + requiredConsecutiveMatches: 4); + +Assert.IsTrue(result.Succeeded, + $"Explorer selection did not settle. Last focus: {result.LastObservation?.FocusedPath ?? "<none>"}"); +``` + +The helper normalizes paths, sets exact selection/focus through Shell COM, retries exact foreground +ownership, and requires consecutive matching snapshots. The focused item matters: multi-select tools +often open item zero/current, not an arbitrary member of the selected set. + +## Recipe 14 — Preserve or reset module process state intentionally + +Write a lifecycle matrix before implementing cleanup: + +| Scenario | Close window | Preserve process | Kill tree and wait | +|---|---:|---:|---:| +| Validate state retained in-process | yes | yes | no | +| Explicitly reset/unpin/reopen | yes | no | yes | +| Renderer terminal failure | best effort | no | yes | + +Use `WindowControl.TryKillProcessTreeByNameAndWait(exactName)` when a fresh process is required. It +waits for the parent and children (for example WebView2) to exit before the next activation. Do not +use it in scenarios whose assertion depends on in-process state. + +## Recipe 15 — Validate composed WinUI/WebView visuals + +Expose a product-owned ready signal first, then compare visible pixels: + +```csharp +var state = window.Find<Element>(By.AccessibilityId("PreviewStateAutomationPeer"), 15_000); +Assert.IsTrue(state.WaitForValue("Loaded", timeoutMS: 60_000)); +VisualAssert.AreEqual(TestContext, window, scenarioSubname: "image"); +``` + +`VisualAssert` uses `ScreenshotVisibleWindow`, DWM frame bounds, exact foreground ownership, and +bounded retries. Keep platform-specific embedded baselines. If a correctly rendered video disagrees +with a screenshot, diagnose capture/z-order before touching the baseline or 95% threshold. + +## Recipe 16 — Give an unaddressable control a test hook + +An icon-only button whose label lives in a `ToolTipService.ToolTip` has **no** UIA Name: the +automation peer builds the name from the content's plain text, and a `FontIcon` has none. There is +nothing for `By.Name` to match and nothing for `By.AccessibilityId` to match either, so the only +test-side option left is clicking raw coordinates derived from a neighbouring control — brittle, +DPI-sensitive, and silently wrong when the layout changes. + +The fix is a **one-attribute product edit**: add `AutomationProperties.AutomationId`. + +```xml +<Button + AutomationProperties.AutomationId="ReloadBtn" + Command="{Binding LoadProcessesCommand}" + Content="{ui:FontIcon Glyph=&#xe72c;, FontSize=16}" + Style="{StaticResource SubtleButtonStyle}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="Reload" /> + </ToolTipService.ToolTip> +</Button> +``` + +```csharp +ui.Find<Button>(By.AccessibilityId("ReloadBtn")).Click(); +``` + +Rules for using it: + +- **`AutomationProperties.AutomationId`, not `x:Name`.** `x:Name` does yield an AutomationId, but it + also generates a code-behind field and turns a pure markup change into something a developer can + bind to and depend on. `AutomationProperties.AutomationId` is inert: no field, no codegen, no + visual, no localization, no behaviour, and it never appears in the accessible name a screen reader + reads. +- **Only when the control is genuinely unaddressable.** Try `By.Name`, `By.AccessibilityId` on an + existing `x:Name`, and `GetValue()` (Recipe 8) first. Do not sprinkle ids over controls that already + resolve. +- **Add the id, not the behaviour.** Anything beyond an id — a hidden automation-peer TextBlock like + ColorPicker's `ColorHexAutomationPeer`, a new property, a state string — is a real product change: + describe it and let the maintainers decide. +- **Name it after the control, not the test**, and keep it stable; it is now part of the module's + automation contract. + --- ## Pitfalls @@ -408,3 +499,69 @@ string result = MeasureWithRetry(() => { MouseHelper.MoveTo(cx, cy); MouseHelper interaction: activate a `NavigationViewItem` with `By.AccessibilityId(...).Click()` (the harness routes it to a coordinate-free UIA invoke), not a raw `MouseHelper`/`MouseClick`. See [ci-stability.md](ci-stability.md) Principle 2. +20. **A condition observed once may be transient.** Deferred Explorer chrome, focus changes, and DWM + composition can invalidate an apparently ready state. Use `WaitHelper.WaitForStable` for exact + foreground/selection/bounds state and require consecutive samples. +21. **Blind retries can undo success.** Activation hotkeys and pin buttons toggle. Once any target + HWND exists, stop resending and wait for initialization. Retry the whole activation only after a + bounded terminal failure and explicit process reset. +22. **Window close is not process reset.** A hidden module process may ignore later show events, while + a pinned process may intentionally hold geometry. Choose preserve vs. + `TryKillProcessTreeByNameAndWait` per scenario (Recipe 14). +23. **Foreground activation is best-effort across integrity levels.** An elevated visible helper + console can permanently block a non-elevated target. Log `GetForegroundWindowInfo()` and fix the + environment (for example launch shared WinAppDriver hidden), rather than adding infinite retries. +24. **Explorer UIA is not the Shell selection authority.** Title readiness and visible file rows do + not prove the exact selected set or focused item. Use `ExplorerShell` (Recipe 13). +25. **`PrintWindow` is not a composed-content oracle.** WinUI/WebView2 can render correctly on video + while `PrintWindow` is blank or incomplete. Use visible DWM capture and verify z-order before + changing valid baselines (Recipe 15). +26. **Read the failure artifacts BEFORE theorising about the product.** Every failed test attaches a + desktop screenshot (and, in pipeline mode, an MP4). Open them first — they show what the UI + actually did, which is the one thing an assertion message cannot tell you. An assertion says + "found 0 rows"; the screenshot says whether the list was empty or whether your *counter* was + wrong. Skipping this step cost ~8 local-VM iterations on File Locksmith and produced a confident, + fully-argued, and entirely wrong "product defect" report — the window had ~10 rows on screen the + whole time while `FindAll<Button>(By.Name("End task"))` returned 0, because the button exposes no + UIA name and the `Button` wrapper filtered out the `Text` matches that winappcli did return. + + Concretely, when a test reports "the UI shows nothing": + + | Ask | Where to look | + |---|---| + | Did the UI really show nothing? | the failure PNG / MP4 | + | Is my selector matching the right control type? | `Session.Inspect()`, or `FindAll<Element>` and print `ControlType` | + | Did my fixture establish its precondition? | make the fixture assert it (Pitfall 27) | + | Is the product genuinely broken? | only after the three above | + + A product-defect claim needs artifact evidence, not inference from an assertion message. If you + catch yourself building a theory about product internals from a counter that returns 0, stop and + open the PNG. +27. **A fixture must prove its own precondition, per unit.** "At least one holder locked the file" + passes when 1 of 2 holders locked it, so the shortfall gets reported later as a product failure. + Assert the exact expected state (e.g. one ready-marker file per holder, written *after* the + operation succeeds) and include the fixture's own diagnostics in the assertion message. Equally, + expectations must track the lifecycle: a fixture that counts "total ever started" fails a test + that deliberately kills one of its processes — count what should be *alive now*. Marker files + outlive crashed/killed processes, so derive the ready count from markers whose PID is still live, + not from every marker ever written. +28. **A background fixture must be non-activating from process creation; hiding its window later is + racy.** `CreateNoWindow=true` is reliable for a direct child, but an elevated test host may need + Explorer to launch a medium-integrity fixture. Opening a generated `.cmd` through Explorer still + creates a console even when the batch uses `start /b`; that console can appear after the first + window enumeration, steal foreground from non-elevated Explorer, and make a strict foreground + assertion fail on only one CI runner. Do not weaken the target's foreground check or add retries. + Eliminate the competing surface: launch the helper hidden from creation. One proven Windows + pattern is an Explorer-opened VBScript using `WScript.Shell.Run(command, 0, False)` and an encoded + PowerShell command; a dedicated launcher using `CREATE_NO_WINDOW` under the intended token is + another. Verify the fixture's ready signal, `MainWindowHandle == 0`, and that its PID does not own + `GetForegroundWindow()`. +29. **Foreground is an interaction contract, not a universal window-readiness assertion.** Keep a + strict, stable foreground check for operations whose meaning depends on focus or coordinates — + Explorer selection/context menus, SendInput, drag, and physical clicks. Do not require an exact + launch-time HWND merely before coordinate-free UIA reads/invokes: WinUI can replace its top-level + HWND, and an interactive scheduled-task host can transiently observe `GetForegroundWindow()==0` + while the failure PNG shows the target visible and unobscured. In that case, attempt focus and log + `GetForegroundWindowInfo()`, but gate readiness on the owning process/window plus the authoritative + UIA element. Never apply this relaxation to Explorer context-menu tests; their focused Shell item + is part of the behavior under test. diff --git a/.github/skills/ui-tests-migration/references/porting-workflow.md b/.github/skills/ui-tests-migration/references/porting-workflow.md index c8d4f8e0aebc..fc1501478ce1 100644 --- a/.github/skills/ui-tests-migration/references/porting-workflow.md +++ b/.github/skills/ui-tests-migration/references/porting-workflow.md @@ -164,6 +164,9 @@ displayed HEX, a canvas color). If an assertion needs a hook the product doesn't - First try the existing readouts: `GetValue()` (reads the Text binding even when `AutomationProperties.Name` overrides the UIA Name), `Inspect(...)` tree walks, clipboard, window geometry. +- If the control is simply unaddressable (an icon-only button whose only label is a tooltip), add + `AutomationProperties.AutomationId` to it — the one sanctioned product edit; see + [patterns-and-pitfalls.md](patterns-and-pitfalls.md) Recipe 16. - If there's truly no signal, **flag it to the user** that a small test-only UIA hook is needed (like ColorPicker's hidden `ColorHexAutomationPeer` TextBlock — `Visibility=Visible, Opacity=0`, bound to the same source). Do **not** add such a hook to product code yourself without sign-off; describe it @@ -184,4 +187,8 @@ that covers it, and explicitly call out any items left as manual-only (e.g. "che - [ ] (A) Every legacy `[TestMethod]` has a `.Next` counterpart; the legacy project is untouched. - [ ] (B) Every actionable sign-off item maps to a test or is explicitly noted as manual-only. - [ ] Toggles/settings the test changes are restored in a `finally`; spawned windows are closed. -- [ ] No product-code edits (or any needed UIA hook is flagged to the user, not silently added). +- [ ] No product-code edits beyond `AutomationProperties.AutomationId` hooks; anything larger was + flagged to the user, not silently added. +- [ ] One deterministic test and then the module suite were run through the local VM loop in + [ui-tests-local-vm](../../ui-tests-local-vm/SKILL.md), using a restored baseline or fresh volume + when clean-profile behavior matters, or the exact host prerequisite/environment blocker was reported. diff --git a/.github/skills/ui-tests-migration/references/project-setup.md b/.github/skills/ui-tests-migration/references/project-setup.md index e7e21f524294..57dcd2042146 100644 --- a/.github/skills/ui-tests-migration/references/project-setup.md +++ b/.github/skills/ui-tests-migration/references/project-setup.md @@ -148,6 +148,33 @@ module's legacy UITests project) and reference it in the csproj: Tests that only assert on **format** (regex like `\d+ x \d+`) or never touch raw coordinates don't need the manifest — which is why ColorPicker/Settings `.Next` projects omit it. +## 4c. (Visual tests only) embed platform baselines + +Add baseline PNGs as embedded resources and use `VisualAssert.AreEqual`: + +```xml +<ItemGroup> + <EmbeddedResource Include="Baseline\*.png" /> +</ItemGroup> +``` + +Resource names must end with the scenario generated by `VisualAssert`: +`<Class>_<CallingMethod>[_<Subname>]_<platform>.png`. The pipeline `platform` values distinguish +targets such as `x64Win10`, `x64Win11`, and `arm64`. Keep separate valid baselines where rendering +really differs; do not regenerate them to hide capture, theme, DPI, or readiness defects. + +Visual comparison runs only when `EnvironmentConfig.IsInPipeline` is true (`TF_BUILD` or `platform` +is set). Set `TF_BUILD=true` and a representative `platform` locally when debugging pipeline capture. +`Session.ScreenshotVisibleWindow` is the correct API for composed WinUI/WebView content. + +## 4d. Explorer-driven tests need no project COM references + +Use `ExplorerShell` from `UITestAutomation.Next`. The framework embeds Shell32/SHDocVw interop and +does not expose COM types in its public API, so consuming test projects should not add their own +`COMReference` items. Use `SetSelectionAndWaitForStable` for selected/focused paths and +`SetViewModeAndIconSizeAndWait` for deterministic icon/list layout; do not duplicate Shell COM +interop in the test project. + ## 5. Build & run ```pwsh diff --git a/.pipelines/signSparsePackages.ps1 b/.pipelines/signSparsePackages.ps1 new file mode 100644 index 000000000000..f2b3fc3c5796 --- /dev/null +++ b/.pipelines/signSparsePackages.ps1 @@ -0,0 +1,334 @@ +<# +.SYNOPSIS +Self-sign PowerToys sparse MSIX shell-extension packages with a machine-trusted TEST certificate so +they register on unsigned CI builds, letting UI tests exercise the real end-user workflow (the modern +Windows 11 context menu) instead of signing-free fallbacks. + +.DESCRIPTION +CI PR-validation builds are produced with codeSign:false, so every sparse-MSIX shell extension +(ImageResizer / PowerRename / FileLocksmith / NewPlus context menus, the CmdPal PowerToysSparse +package) ships UNSIGNED. PowerToys registers these at module-enable time via +PackageManager.AddPackageByUriAsync, which requires a signature that chains to a trusted root, so on +CI they fail with 0x800B0100 (TRUST_E_NOSIGNATURE) and the modern context menu never appears. + +Run this on the test agent AFTER the build is downloaded/installed and BEFORE PowerToys enables the +module. For every package it: + 1. reads the manifest Publisher subject, + 2. ensures a self-signed code-signing certificate with that exact subject exists, + 3. force-trusts that certificate (LocalMachine + CurrentUser Root and TrustedPeople), and + 4. signs the package with signtool. + +This asserts NO security -- it is a test-only trust anchor for validating normal app usage. It only +signs packages that are not already validly signed (unless -Force), so real framework packages +(VCLibs, WindowsAppSDK) are left untouched. + +.PARAMETER PackageRoot +One or more folders to search recursively for sparse packages. Missing folders are skipped, so you +can pass both the run-in-place build tree and the installed location: + -PackageRoot "$(Pipeline.Workspace)\build-x64-Release", "$env:ProgramFiles\PowerToys" + +.PARAMETER Include +Filename patterns to sign. Defaults to *.msix and *.appx. + +.PARAMETER RequiredPackage +Filename patterns that must be found and end with a Valid signature. Missing, unsigned, or untrusted +matches make the script fail after attempting all packages. + +.PARAMETER Force +Re-sign even packages that already carry a valid signature. + +.PARAMETER SkipLocalTrust +Sign without importing the test certificate into this machine's trust stores. Use when signing on a +build host and registering the package somewhere else (for example packaging a UI-test payload on the +host and running it in a VM), so the build machine never gains a test trust anchor. + +.PARAMETER ExportCertificatePath +Write the public certificate to this path so the machine that registers the package can trust it. + +.EXAMPLE +.\signSparsePackages.ps1 -PackageRoot "$env:ProgramFiles\PowerToys" ` + -RequiredPackage ImageResizerContextMenuPackage.msix + +.EXAMPLE +# Local sideloading into a UI-test VM runtime: +.\signSparsePackages.ps1 -PackageRoot "C:\PowerToysUiTestRun\PowerToys" + +.EXAMPLE +# Sign a payload on the host, trust it only inside the VM that will register it: +.\signSparsePackages.ps1 -PackageRoot "X:\payload\product" -SkipLocalTrust ` + -ExportCertificatePath "X:\payload\pt-test-signer.cer" +#> +param( + [Parameter(Mandatory = $true)] + [string[]]$PackageRoot, + + [Parameter()] + [string[]]$Include = @('*.msix', '*.appx'), + + [Parameter()] + [string[]]$RequiredPackage = @(), + + [switch]$Force, + + [switch]$SkipLocalTrust, + + [Parameter()] + [string]$ExportCertificatePath +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Select-SignToolByArch { + param([string[]]$Paths) + + $paths = @($Paths | Where-Object { $_ } | Select-Object -Unique) + if (-not $paths) { return $null } + $archPref = @($env:PROCESSOR_ARCHITECTURE, 'x64', 'x86', 'arm64') | + ForEach-Object { $_.ToLower() } | Select-Object -Unique + foreach ($arch in $archPref) { + $match = $paths | Where-Object { $_ -match "\\$arch\\" } | Select-Object -First 1 + if ($match) { return $match } + } + return $paths[0] +} + +# 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. +function Find-SignTool { + $cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + + $found = @() + $kitRoots = @( + "${env:ProgramFiles(x86)}\Windows Kits", + "$env:ProgramFiles\Windows Kits", + "$env:ProgramW6432\Windows Kits" + ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique + foreach ($root in $kitRoots) { + # Scope to bin\ and the App Certification Kit so the huge Include\ / Lib\ trees are skipped. + $scopes = Get-ChildItem -Path $root -Directory -Recurse -Depth 1 -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq 'bin' -or $_.Name -eq 'App Certification Kit' } | + Select-Object -ExpandProperty FullName + foreach ($scope in $scopes) { + $found += Get-ChildItem -Path $scope -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + } + } + + $nugetRoots = @($env:NUGET_PACKAGES, (Join-Path $env:USERPROFILE '.nuget\packages')) | + Where-Object { $_ } | + ForEach-Object { Join-Path $_ 'microsoft.windows.sdk.buildtools' } | + Where-Object { Test-Path $_ } | Select-Object -Unique + foreach ($root in $nugetRoots) { + $found += Get-ChildItem -Path $root -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + } + + return Select-SignToolByArch -Paths $found +} + +# Last resort when the agent has no Windows SDK: fetch signtool from the public +# Microsoft.Windows.SDK.BuildTools NuGet package (cached in TEMP across runs). Best-effort. +function Get-SignToolFromNuget { + try { + $index = Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/index.json' -UseBasicParsing + $version = @($index.versions | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' })[-1] + if (-not $version) { return $null } + + $dest = Join-Path $env:TEMP "pt-sdk-buildtools-$version" + if (-not (Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue)) { + Write-Host "signtool not found on the agent; fetching Windows SDK BuildTools $version from NuGet." + $nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.zip" + Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/$version/microsoft.windows.sdk.buildtools.$version.nupkg" -OutFile $nupkg -UseBasicParsing + Expand-Archive -Path $nupkg -DestinationPath $dest -Force + Remove-Item $nupkg -Force -ErrorAction SilentlyContinue + } + $paths = Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + return Select-SignToolByArch -Paths $paths + } + catch { + Write-Warning "Could not obtain signtool from NuGet: $($_.Exception.Message)" + return $null + } +} + +# Read <Identity Publisher="..."> straight out of the .msix/.appx (a zip) without extracting it. +function Get-PackagePublisher { + param([string]$PackagePath) + + $zip = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $entry = $zip.GetEntry('AppxManifest.xml') + if (-not $entry) { return $null } + $reader = New-Object System.IO.StreamReader($entry.Open()) + try { $xml = [xml]$reader.ReadToEnd() } finally { $reader.Dispose() } + return $xml.Package.Identity.Publisher + } + finally { + $zip.Dispose() + } +} + +function Import-CertTrust { + param( + [string]$CerPath, + [string]$Thumbprint, + [string]$StorePath, + [switch]$Optional + ) + + if (Get-ChildItem $StorePath -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $Thumbprint }) { + return $true + } + try { + Import-Certificate -FilePath $CerPath -CertStoreLocation $StorePath -ErrorAction Stop | Out-Null + return $true + } + catch { + if ($Optional) { + Write-Warning "Could not import test cert into $StorePath (admin may be required): $($_.Exception.Message)" + return $false + } + throw + } +} + +$certCache = @{} +function Get-TrustedSigningCert { + param([string]$Subject) + + if ($certCache.ContainsKey($Subject)) { return $certCache[$Subject] } + + $cert = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $Subject -and $_.HasPrivateKey } | + Sort-Object NotAfter -Descending | Select-Object -First 1 + + if (-not $cert) { + Write-Host "Creating self-signed test certificate for: $Subject" + $cert = New-SelfSignedCertificate -Subject $Subject ` + -CertStoreLocation Cert:\CurrentUser\My ` + -KeyAlgorithm RSA -KeyLength 2048 ` + -Type CodeSigningCert -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(1) + } + + # Force-trust so AddPackageByUriAsync accepts the signature. A self-signed cert is its own root, + # so it must live in a Root store (chain) and TrustedPeople (AppX sideload allow-list). Use the + # LocalMachine stores: they import silently and the elevated CI test agent can write them. + # CurrentUser\Root is deliberately NOT used -- importing into the user Root store raises a CryptoAPI + # consent dialog that fails non-interactively ("UI is not allowed in this operation"), even elevated. + $cerPath = Join-Path $env:TEMP ("pt-test-signer-{0}.cer" -f $cert.Thumbprint) + Export-Certificate -Cert $cert -FilePath $cerPath -Force | Out-Null + + if ($ExportCertificatePath) { + $exportParent = Split-Path $ExportCertificatePath -Parent + if ($exportParent -and -not (Test-Path $exportParent)) { + New-Item $exportParent -ItemType Directory -Force | Out-Null + } + Copy-Item $cerPath $ExportCertificatePath -Force + Write-Host "Exported public certificate to: $ExportCertificatePath" + } + + if ($SkipLocalTrust) { + Write-Host "Skipping local trust for '$Subject'; trust the exported certificate where the package is registered." + $certCache[$Subject] = $cert + return $cert + } + + $rootTrusted = Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\LocalMachine\Root' -Optional + Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\LocalMachine\TrustedPeople' -Optional | Out-Null + Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\CurrentUser\TrustedPeople' -Optional | Out-Null + if (-not $rootTrusted) { + Write-Warning "Could not establish machine root trust for '$Subject' (run elevated). Signed packages may not register." + } + + $certCache[$Subject] = $cert + return $cert +} + +$packages = @() +foreach ($root in $PackageRoot) { + if (-not (Test-Path $root)) { + Write-Host "Skipping missing package root: $root" + continue + } + $packages += Get-ChildItem -Path $root -Recurse -File -Include $Include -ErrorAction SilentlyContinue +} +$packages = $packages | Sort-Object FullName -Unique + +if (-not $packages) { + if ($RequiredPackage.Count -gt 0) { + throw "No packages found under '$($PackageRoot -join ', ')' while requiring: $($RequiredPackage -join ', ')." + } + + Write-Host "No packages found under: $($PackageRoot -join ', ')" + return +} + +$requiredPackages = @() +foreach ($pattern in ($RequiredPackage | Where-Object { $_ } | Select-Object -Unique)) { + $matches = @($packages | Where-Object { $_.Name -like $pattern }) + if ($matches.Count -eq 0) { + throw "Required sparse package '$pattern' was not found under: $($PackageRoot -join ', ')." + } + + $requiredPackages += $matches +} +$requiredPackages = @($requiredPackages | Sort-Object FullName -Unique) + +$signed = 0 +$signtool = $null +foreach ($pkg in $packages) { + if (-not $Force) { + $existing = Get-AuthenticodeSignature -FilePath $pkg.FullName + if ($existing.Status -eq 'Valid') { + Write-Host "Already validly signed, skipping: $($pkg.Name)" + continue + } + } + + $publisher = $null + try { $publisher = Get-PackagePublisher -PackagePath $pkg.FullName } catch { } + if (-not $publisher) { + Write-Host "No manifest publisher, skipping: $($pkg.Name)" + continue + } + + if (-not $signtool) { + $signtool = Find-SignTool + if (-not $signtool) { $signtool = Get-SignToolFromNuget } + if (-not $signtool) { throw 'signtool.exe not found and could not be fetched from NuGet. Install the Windows SDK.' } + Write-Host "Using signtool: $signtool" + } + + $cert = Get-TrustedSigningCert -Subject $publisher + Write-Host "Signing $($pkg.Name) (Publisher: $publisher)" + & $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $pkg.FullName + if ($LASTEXITCODE -ne 0) { + Write-Warning "signtool failed for $($pkg.Name) (exit $LASTEXITCODE)" + continue + } + + $verify = Get-AuthenticodeSignature -FilePath $pkg.FullName + if ($verify.Status -eq 'Valid') { + $signed++ + } + else { + Write-Warning "Signature not Valid after signing $($pkg.Name): $($verify.Status)" + } +} + +if ($requiredPackages.Count -gt 0) { + $invalidRequiredPackages = @($requiredPackages | Where-Object { + (Get-AuthenticodeSignature -FilePath $_.FullName).Status -ne 'Valid' + }) + if ($invalidRequiredPackages.Count -gt 0) { + throw "Required sparse package(s) are not validly signed and trusted: $($invalidRequiredPackages.FullName -join ', ')." + } + + Write-Host "Verified required sparse package(s): $($requiredPackages.FullName -join ', ')" +} + +Write-Host "Signed $signed package(s) with a trusted test certificate." diff --git a/.pipelines/v2/templates/job-test-project.yml b/.pipelines/v2/templates/job-test-project.yml index fb79b47c7a43..bc590f2f3277 100644 --- a/.pipelines/v2/templates/job-test-project.yml +++ b/.pipelines/v2/templates/job-test-project.yml @@ -177,17 +177,47 @@ jobs: inputs: displaySettings: 'optimal' + # Sign the sparse shell-extension MSIX packages with a machine-trusted TEST certificate so they + # register on unsigned PR builds and the UI tests can drive the real modern (Win11 tier-1) context + # menu instead of the signing-free fallback. Test-only trust anchor; asserts no security. All roots + # are searched recursively (buildNow run-in-place tree + complete machine/per-user installs). + # Image Resizer has no Windows 11 classic-menu fallback, so its focused and all-module jobs require + # the signed/trusted package; unrelated jobs keep this setup best-effort. + - pwsh: | + $packageRoots = @( + "$(Pipeline.Workspace)\$(TestArtifactsName)", + "$env:ProgramFiles\PowerToys", + "$env:LOCALAPPDATA\PowerToys") + $modulesRaw = '${{ join(';', parameters.uiTestModules) }}' + $requiresImageResizer = '$(TestPlatform)' -ne 'x64Win10' -and ( + [string]::IsNullOrWhiteSpace($modulesRaw) -or + @($modulesRaw -split ';' | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0) + + if ($requiresImageResizer) { + & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" ` + -PackageRoot $packageRoots ` + -RequiredPackage 'ImageResizerContextMenuPackage.msix' + } else { + try { + & "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" -PackageRoot $packageRoots + } catch { + Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)" + } + } + displayName: "Sign sparse MSIX packages (test trust)" + # Start WinAppDriver once for the whole job — WinAppDriver's documented CI pattern # (https://github.com/microsoft/WinAppDriver/blob/master/Docs/CI_AzureDevOps.md). Launching it - # detached gives it its own console whose stdin blocks, so it stays alive for the run instead of - # reading EOF and exiting the moment it starts listening (the failure mode when a test host launches - # it as a child). The legacy UITest harness reuses an already-listening instance rather than + # detached gives it its own hidden console whose stdin blocks, so it stays alive for the run without + # stealing foreground from non-elevated apps under test. This also avoids reading EOF and exiting the + # moment it starts listening (the failure mode when a test host launches it as a child). The legacy + # UITest harness reuses an already-listening instance rather than # relaunching it per test, so this removes the per-assembly launch cost. The winappcli-based .Next # tests don't use WinAppDriver. Best-effort: if the pre-start fails, each assembly still launches its own. - pwsh: | $winapp = "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe" if (Test-Path $winapp) { - Start-Process -FilePath $winapp + Start-Process -FilePath $winapp -WindowStyle Hidden $deadline = (Get-Date).AddSeconds(30) $ready = $false diff --git a/PowerToys.slnx b/PowerToys.slnx index c93236d9a119..2e23351c44d4 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -58,6 +58,10 @@ <Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|x64" Project="x64" /> </Project> + <Project Path="src/common/UITestAutomation.Next.UnitTests/UITestAutomation.Next.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> <Project Path="src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj" Id="1a066c63-64b3-45f8-92fe-664e1cce8077" /> <Project Path="src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj" Id="8b5cfb38-ccba-40a8-ad7a-89c57b070884" /> <Project Path="src/common/updating/UnitTests/UpdatingUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-abcd-ef1234567890" /> @@ -461,6 +465,10 @@ </Folder> <Folder Name="/modules/FileLocksmith/Tests/"> <Project Path="src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-1234-567890abcdef" /> + <Project Path="src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmith.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> </Folder> <Folder Name="/modules/Hosts/"> <Project Path="src/modules/Hosts/Hosts/Hosts.csproj"> @@ -502,6 +510,10 @@ <Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|x64" Project="x64" /> </Project> + <Project Path="src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizer.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> </Folder> <Folder Name="/modules/interface/"> <File Path="src/modules/interface/powertoy_module_interface.h" /> @@ -843,6 +855,10 @@ <Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|x64" Project="x64" /> </Project> + <Project Path="src/modules/peek/Peek.UITests.Next/Peek.UITests.Next.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> <Project Path="src/modules/peek/peek/peek.vcxproj" Id="a1425b53-3d61-4679-8623-e64a0d3d0a48" /> </Folder> <Folder Name="/modules/PowerAccent/"> @@ -979,6 +995,10 @@ <Project Path="src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj" Id="2bbc9e33-21ec-401c-84da-bb6590a9b2aa" /> </Folder> <Folder Name="/modules/previewpane/Tests/"> + <Project Path="src/modules/previewpane/PreviewPane.UITests/PreviewPane.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> <Project Path="src/modules/previewpane/UnitTests-BgcodePreviewHandler/Preview.BgcodePreviewHandler.UnitTests.csproj"> <Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|x64" Project="x64" /> diff --git a/doc/devdocs/development/ui-tests.md b/doc/devdocs/development/ui-tests.md index 941f9dacd4c1..443873a778e6 100644 --- a/doc/devdocs/development/ui-tests.md +++ b/doc/devdocs/development/ui-tests.md @@ -1,8 +1,38 @@ # UI tests framework - A specialized UI test framework for PowerToys that makes it easy to write UI tests for PowerToys modules or settings. Let's start writing UI tests! +PowerToys provides UI-test frameworks for modules and Settings. New tests should use +`Microsoft.PowerToys.UITest.Next`, which drives Windows UI Automation through `winappcli` and runs as +a Microsoft.Testing.Platform executable. The legacy `Microsoft.PowerToys.UITest` framework uses +WinAppDriver/Selenium and remains documented for existing suites and migration baselines. -## Before running tests +## Agent-assisted workflows + +Two repository skills cover the complete implementation and validation loop: + +- [UI-tests migration skill](../../../.github/skills/ui-tests-migration/SKILL.md): create new + `.Next` test projects, port legacy WinAppDriver tests, design stable selectors/waits/lifecycle, and + prepare tests for CI. +- [Local-VM UI-tests skill](../../../.github/skills/ui-tests-local-vm/SKILL.md): create persistent + Windows 10 and Windows 11 Hyper-V guests, stage current build/test artifacts, execute tests in a + standard-user interactive desktop, and collect durable TRX/log/screenshot/video evidence. + +For new or migrated tests, use both skills. Build first, then use the local VMs as the default live +agentic loop: run one deterministic test, diagnose and fix it, and finally widen to the complete +module suite on both supported Windows versions. + +## Before running tests + +### `.Next` tests + +- Build the PowerToys runtime and `.UITests.Next` test executable. +- Install the pinned `winappcli` runtime or set `WINAPP_CLI_PATH`. The pipeline helper is + `.pipelines/InstallWinAppCli.ps1`. +- Use a live interactive desktop. UIA, foreground input, Explorer, hotkeys, and rendering do not work + in session 0. +- Exit an existing PowerToys instance before a host-desktop run. The harness owns the runner and + module lifecycle. + +### Legacy tests - Install Windows Application Driver v1.2.1 from https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1 to the default directory (`C:\Program Files (x86)\Windows Application Driver`) @@ -10,12 +40,101 @@ ## Running tests +### `.Next` tests + +Build the focused project with the repository script, then run the produced Microsoft.Testing.Platform +executable directly: + +```pwsh +tools\build\build.cmd ` + -Path src\modules\<Module>\Tests\<Module>.UITests.Next ` + -Platform x64 ` + -Configuration Debug + +$exe = 'x64\Debug\tests\<Module>.UITests.Next\net10.0-windows10.0.26100.0\<Module>.UITests.Next.exe' +& $exe ` + --filter 'TestCategory=<Module>' ` + --report-trx ` + --report-trx-filename module.trx ` + --results-directory .\TestResults\<Module> ` + --timeout 7m +``` + +Use explicit filter properties such as `Name=`, `Name~`, `FullyQualifiedName~`, or `TestCategory=`. +A bare display name can select zero tests. The `7m` timeout above is a focused-filter example; choose +a larger value for a module or project-wide run. + +### Legacy tests + - Exit PowerToys if it's running. - Open `PowerToys.slnx` in Visual Studio and build the solution. - Run tests in the Test Explorer (`Test > Test Explorer` or `Ctrl+E, T`). +## Running `.Next` tests in persistent local VMs + +The supported local backend is a pair of persistent Hyper-V guests driven through PowerShell Direct: +Windows 10 and Windows 11, each with an already logged-on standard-user desktop. The VMs reveal +first-run, profile, Explorer, WebView2, foreground, and lifecycle assumptions without modifying the +host profile, while retaining staged payloads for a fast edit/build/rerun loop. + +### One-time host setup + +Scaffold a VM root outside the repository, then follow the generated next steps to create the +untracked configuration: + +```pwsh +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVm.ps1 ` + -DestinationRoot C:\PowerToysUiTestVm + +pwsh .github\skills\ui-tests-local-vm\scripts\Initialize-LocalVmHost.ps1 ` + -VmRoot C:\PowerToysUiTestVm ` + -CheckOnly +``` + +If `-CheckOnly` reports `IsReady=false`, a human must run the elevated setup command it prints. +Hyper-V group membership, the DPAPI-protected guest administrator credential, and guest creation +cannot be completed by an agent. See the [setup reference](../../../.github/skills/ui-tests-local-vm/references/setup.md) +for install media, `vm.config.psd1`, Windows 10/11 guest creation, and baseline checkpoints. + +### Run the agentic loop + +Create a module exchange containing `ui-tests.zip`, `powertoys-runtime.zip`, `winappcli.zip`, and +`dotnet-runtime.zip` as described in the +[agentic-loop reference](../../../.github/skills/ui-tests-local-vm/references/agentic-loop.md). Payloads +are extracted to guest-local storage; tests are never run directly from a host share. + +```pwsh +$vmRoot = 'C:\PowerToysUiTestVm' +$exchange = "$vmRoot\shared\PowerToysUiTests\<Module>" + +pwsh .github\skills\ui-tests-local-vm\scripts\Invoke-LocalVmUiTest.ps1 ` + -VmName PowerToysUiTest-Win11 ` + -ConfigurationPath "$vmRoot\vm.config.psd1" ` + -VmRoot $vmRoot ` + -ExchangeRoot $exchange ` + -TestExecutable '<Module>.UITests.Next.exe' ` + -Filter 'Name=<Module>.FocusedTest' ` + -Platform x64Win11 ` + -BuildLabel (git rev-parse HEAD) ` + -SuiteTimeout 15m ` + -TimeoutMinutes 25 ` + -ReuseStagedPayload +``` + +The controller starts the guest if needed, validates the standard-user token, Explorer session, and +desktop size, then runs the test through a limited interactive scheduled task. It streams progress +and returns `status.json`, TRX counters, per-test failures, logs, screenshots, and retained failure +recordings under `<ExchangeRoot>\LocalVmResults\<runId>`. + +After each source change, rebuild and replace only the changed archive, then rerun the same focused +filter with `-ReuseStagedPayload`. Widen only after that behavior is understood. A module is complete +only after the full category filter passes with `executed == total` on both Windows 10 and Windows 11; +restore the baseline checkpoint for the final clean-profile confirmation. See the local-VM +[troubleshooting guide](../../../.github/skills/ui-tests-local-vm/references/troubleshooting.md) for +desktop, PowerShell Direct, payload, shell-extension signing, and evidence failures. + ## Running tests in pipeline The PowerToys UI test pipeline provides flexible options for building and testing: @@ -68,6 +187,14 @@ The PowerToys UI test pipeline provides flexible options for building and testin - Pipeline: https://microsoft.visualstudio.com/Dart/_build?definitionId=161438&_a=summary ## How to add the first UI tests for your modules + +Use the [UI-tests migration skill](../../../.github/skills/ui-tests-migration/SKILL.md) for new +`.Next` projects and ports. It contains the current executable project scaffold, API mapping, naming, +CI-stability checklist, and validated examples. + +The project sample below describes the **legacy WinAppDriver framework** and is retained for existing +legacy suites. Do not use it as the starting point for a new `.Next` project. + - Follow the naming convention: ![{ModuleFolder}/Tests/{ModuleName}-{TestType(Fuzz/UI/Unit)}Tests](images/uitests/naming.png) - Create a new project and add the following references to the project file. Change the OutputPath to your own module's path. ``` diff --git a/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs b/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs new file mode 100644 index 000000000000..88648e891657 --- /dev/null +++ b/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs @@ -0,0 +1,138 @@ +// 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.Text.Json.Nodes; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITestAutomationNext.UnitTests; + +[TestClass] +public class SettingsConfigHelperTests +{ + private static readonly string[] ExpectedModuleNames = + [ + "AdvancedPaste", + "AlwaysOnTop", + "Awake", + "CmdNotFound", + "CmdPal", + "ColorPicker", + "CropAndLock", + "CursorWrap", + "EnvironmentVariables", + "FancyZones", + "File Explorer Preview", + "File Locksmith", + "FindMyMouse", + "GrabAndMove", + "Hosts", + "Image Resizer", + "Keyboard Manager", + "LightSwitch", + "Measure Tool", + "MouseHighlighter", + "MouseJump", + "MousePointerCrosshairs", + "MouseWithoutBorders", + "NewPlus", + "Peek", + "PowerDisplay", + "PowerRename", + "PowerToys Run", + "QuickAccent", + "RegistryPreview", + "Shortcut Guide", + "TextExtractor", + "Workspaces", + "ZoomIt", + ]; + + [TestMethod] + public void ConfigureGlobalModuleSettingsSeedsExactFreshProfileBaseline() + { + var root = new JsonObject(); + + SettingsConfigHelper.ConfigureGlobalModuleSettings(root, "Image Resizer"); + + var enabled = root["enabled"]!.AsObject(); + CollectionAssert.AreEquivalent(ExpectedModuleNames, enabled.Select(property => property.Key).ToArray()); + foreach (var moduleName in ExpectedModuleNames) + { + Assert.AreEqual(moduleName == "Image Resizer", enabled[moduleName]!.GetValue<bool>(), moduleName); + } + } + + [TestMethod] + public void ConfigureGlobalModuleSettingsHandlesUnknownModuleKeys() + { + var root = new JsonObject + { + ["enabled"] = new JsonObject + { + ["ExistingFutureModule"] = true, + }, + }; + + SettingsConfigHelper.ConfigureGlobalModuleSettings(root, "RequestedFutureModule"); + + var enabled = root["enabled"]!.AsObject(); + Assert.IsFalse(enabled["ExistingFutureModule"]!.GetValue<bool>()); + Assert.IsTrue(enabled["RequestedFutureModule"]!.GetValue<bool>()); + } + + [TestMethod] + public void PreserveFileRestoresExistingBytes() + { + var root = CreateTemporaryDirectory(); + var path = Path.Combine(root, "module", "settings.json"); + var original = new byte[] { 0xEF, 0xBB, 0xBF, 0x7B, 0x7D }; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, original); + + using (SettingsConfigHelper.PreserveFile(path)) + { + File.WriteAllText(path, "changed"); + } + + CollectionAssert.AreEqual(original, File.ReadAllBytes(path)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public void PreserveFileDeletesFileCreatedInsideScope() + { + var root = CreateTemporaryDirectory(); + var path = Path.Combine(root, "module", "settings.json"); + + try + { + using (SettingsConfigHelper.PreserveFile(path)) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "created by test"); + } + + Assert.IsFalse(File.Exists(path)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string CreateTemporaryDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "PowerToys-UITestAutomationNext-UnitTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/src/common/UITestAutomation.Next.UnitTests/UITestAutomation.Next.UnitTests.csproj b/src/common/UITestAutomation.Next.UnitTests/UITestAutomation.Next.UnitTests.csproj new file mode 100644 index 000000000000..6ab0f6176b78 --- /dev/null +++ b/src/common/UITestAutomation.Next.UnitTests/UITestAutomation.Next.UnitTests.csproj @@ -0,0 +1,25 @@ +<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> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <IsPackable>false</IsPackable> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <OutputType>Exe</OutputType> + <RootNamespace>Microsoft.PowerToys.UITestAutomationNext.UnitTests</RootNamespace> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITestAutomation.Next.UnitTests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\UITestAutomation.Next\UITestAutomation.Next.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/common/UITestAutomation.Next.UnitTests/WaitHelperTests.cs b/src/common/UITestAutomation.Next.UnitTests/WaitHelperTests.cs new file mode 100644 index 000000000000..fe5cbdc8cc74 --- /dev/null +++ b/src/common/UITestAutomation.Next.UnitTests/WaitHelperTests.cs @@ -0,0 +1,96 @@ +// 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.UITestAutomationNext.UnitTests; + +[TestClass] +public class WaitHelperTests +{ + private static readonly int[] ExpectedRecoveredValues = [1, 2]; + + [TestMethod] + public void WaitForStableRequiresConsecutiveMatches() + { + var observations = new Queue<bool>([true, false, true, true, true]); + var observationCount = 0; + + var result = WaitHelper.WaitForStable( + observe: () => + { + observationCount++; + return observations.Dequeue(); + }, + isMatch: value => value, + timeoutMS: 1_000, + requiredConsecutiveMatches: 3, + pollIntervalMS: 1); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(5, observationCount); + Assert.AreEqual(3, result.ConsecutiveMatches); + } + + [TestMethod] + public void WaitForStableRunsRecoveryOnMismatch() + { + var observations = new Queue<int>([1, 2, 3]); + var recoveredValues = new List<int>(); + + var result = WaitHelper.WaitForStable( + observe: observations.Dequeue, + isMatch: value => value == 3, + timeoutMS: 1_000, + pollIntervalMS: 1, + recover: value => recoveredValues.Add(value)); + + Assert.IsTrue(result.Succeeded); + CollectionAssert.AreEqual(ExpectedRecoveredValues, recoveredValues); + } + + [TestMethod] + public void WaitForStableReturnsLastObservationOnTimeout() + { + var observation = 0; + + var result = WaitHelper.WaitForStable( + observe: () => ++observation, + isMatch: _ => false, + timeoutMS: 20, + pollIntervalMS: 1); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(observation, result.LastObservation); + Assert.IsTrue(observation > 0); + } + + [TestMethod] + public void WaitForStableRetriesOnlyClassifiedExceptions() + { + var attempts = 0; + var transient = new InvalidOperationException("Transient"); + + var result = WaitHelper.WaitForStable<int>( + observe: () => + { + attempts++; + if (attempts == 1) + { + throw transient; + } + + return 42; + }, + isMatch: value => value == 42, + timeoutMS: 1_000, + pollIntervalMS: 1, + shouldRetryException: exception => ReferenceEquals(exception, transient)); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(2, attempts); + Assert.IsNull(result.LastException); + } +} diff --git a/src/common/UITestAutomation.Next.UnitTests/WinappCliTests.cs b/src/common/UITestAutomation.Next.UnitTests/WinappCliTests.cs new file mode 100644 index 000000000000..5c038df143da --- /dev/null +++ b/src/common/UITestAutomation.Next.UnitTests/WinappCliTests.cs @@ -0,0 +1,56 @@ +// 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.UITestAutomationNext.UnitTests; + +[TestClass] +[DoNotParallelize] +public sealed class WinappCliTests +{ + private string? originalInvokeTimeout; + + [TestInitialize] + public void SaveEnvironment() + { + originalInvokeTimeout = Environment.GetEnvironmentVariable(WinappCli.InvokeTimeoutSecondsEnvironmentVariable); + } + + [TestCleanup] + public void RestoreEnvironment() + { + Environment.SetEnvironmentVariable(WinappCli.InvokeTimeoutSecondsEnvironmentVariable, originalInvokeTimeout); + } + + [TestMethod] + public void ResolveInvokeTimeoutHonorsEnvironmentOverride() + { + Environment.SetEnvironmentVariable(WinappCli.InvokeTimeoutSecondsEnvironmentVariable, "180"); + + Assert.AreEqual(TimeSpan.FromSeconds(180), WinappCli.ResolveInvokeTimeout([])); + } + + [TestMethod] + [DataRow("invalid")] + [DataRow("0")] + [DataRow("3601")] + public void ResolveInvokeTimeoutRejectsInvalidEnvironmentOverride(string value) + { + Environment.SetEnvironmentVariable(WinappCli.InvokeTimeoutSecondsEnvironmentVariable, value); + + Assert.AreEqual(TimeSpan.FromSeconds(60), WinappCli.ResolveInvokeTimeout([])); + } + + [TestMethod] + public void ResolveInvokeTimeoutExtendsPastLongerCommandTimeout() + { + Environment.SetEnvironmentVariable(WinappCli.InvokeTimeoutSecondsEnvironmentVariable, "180"); + + Assert.AreEqual( + TimeSpan.FromSeconds(230), + WinappCli.ResolveInvokeTimeout(["wait-for", "target", "--timeout", "200000"])); + } +} diff --git a/src/common/UITestAutomation.Next/ExplorerShell.cs b/src/common/UITestAutomation.Next/ExplorerShell.cs new file mode 100644 index 000000000000..bb67a8163d36 --- /dev/null +++ b/src/common/UITestAutomation.Next/ExplorerShell.cs @@ -0,0 +1,471 @@ +// 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 SHDocVw; + +namespace Microsoft.PowerToys.UITest.Next; + +/// <summary>File Explorer selection helpers backed by the Shell COM view used by Explorer itself.</summary> +public static class ExplorerShell +{ + private static readonly Guid ShellApplicationClassId = new("13709620-C279-11CE-A49E-444553540000"); + private const int ShellViewSelect = 0x1; + private const int ShellViewDeselectOthers = 0x4; + private const int ShellViewEnsureVisible = 0x8; + private const int ShellViewFocused = 0x10; + + public enum ViewMode : uint + { + Icons = 1, + Details = 4, + } + + public sealed record SelectionSnapshot(IReadOnlySet<string> SelectedPaths, string? FocusedPath); + + public sealed record ViewSnapshot(ViewMode Mode, int IconSize); + + private sealed record ReadinessSnapshot(bool IsForeground, SelectionSnapshot? Selection); + + /// <summary> + /// Set the exact selected path set and focused item, then require both selection and foreground + /// ownership to remain stable across consecutive Shell snapshots. + /// </summary> + public static WaitHelper.StableWaitResult<SelectionSnapshot> SetSelectionAndWaitForStable( + IntPtr explorerWindow, + IReadOnlyCollection<string> selectedPaths, + string focusedPath, + int timeoutMS = 30_000, + int requiredConsecutiveMatches = 4, + int pollIntervalMS = 250) + { + ArgumentNullException.ThrowIfNull(selectedPaths); + ArgumentException.ThrowIfNullOrWhiteSpace(focusedPath); + if (explorerWindow == IntPtr.Zero) + { + throw new ArgumentException("Explorer HWND must not be zero.", nameof(explorerWindow)); + } + + var normalizedPaths = selectedPaths + .Select(NormalizePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var normalizedFocusedPath = NormalizePath(focusedPath); + if (normalizedPaths.Count == 0 || !normalizedPaths.Contains(normalizedFocusedPath)) + { + throw new ArgumentException("The selected paths must contain the focused path.", nameof(selectedPaths)); + } + + var result = WaitHelper.WaitForStable( + observe: () => new ReadinessSnapshot( + WindowControl.GetForegroundWindowHandle() == explorerWindow, + TryGetSelection(explorerWindow)), + isMatch: snapshot => snapshot is not null && + snapshot.IsForeground && + snapshot.Selection is not null && + snapshot.Selection.SelectedPaths.SetEquals(normalizedPaths) && + string.Equals(snapshot.Selection.FocusedPath, normalizedFocusedPath, StringComparison.OrdinalIgnoreCase), + timeoutMS: timeoutMS, + requiredConsecutiveMatches: requiredConsecutiveMatches, + pollIntervalMS: pollIntervalMS, + recover: snapshot => + { + if (snapshot?.IsForeground != true) + { + WindowControl.TryBringToForeground(explorerWindow); + } + else + { + TrySetSelection(explorerWindow, normalizedPaths, normalizedFocusedPath); + } + }); + + return new WaitHelper.StableWaitResult<SelectionSnapshot>( + result.Succeeded, + result.LastObservation?.Selection, + result.ConsecutiveMatches, + result.LastException); + } + + /// <summary> + /// Set an Explorer folder view's mode and icon size through the Shell automation object, then + /// require both values to remain stable across consecutive observations. + /// </summary> + public static WaitHelper.StableWaitResult<ViewSnapshot> SetViewModeAndIconSizeAndWait( + IntPtr explorerWindow, + ViewMode mode, + int iconSize, + int timeoutMS = 15_000, + int requiredConsecutiveMatches = 3, + int pollIntervalMS = 250) + { + if (explorerWindow == IntPtr.Zero) + { + throw new ArgumentException("Explorer HWND must not be zero.", nameof(explorerWindow)); + } + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(iconSize); + + return WaitHelper.WaitForStable( + observe: () => TryGetView(explorerWindow), + isMatch: snapshot => snapshot is not null && + snapshot.Mode == mode && + snapshot.IconSize == iconSize, + timeoutMS: timeoutMS, + requiredConsecutiveMatches: requiredConsecutiveMatches, + pollIntervalMS: pollIntervalMS, + recover: _ => TrySetView(explorerWindow, mode, iconSize)); + } + + /// <summary>Read the current selected path set and focused path from an Explorer window.</summary> + public static SelectionSnapshot? TryGetSelection(IntPtr explorerWindow) + { + object? shellObject = null; + ShellWindows? shellWindows = null; + + try + { + var shellType = Type.GetTypeFromCLSID(ShellApplicationClassId, throwOnError: true)!; + shellObject = Activator.CreateInstance(shellType); + var shell = (Shell32.IShellDispatch2)shellObject!; + shellWindows = shell.Windows(); + foreach (IWebBrowserApp browser in shellWindows) + { + object? document = null; + try + { + if (browser.HWND != explorerWindow.ToInt64()) + { + continue; + } + + document = browser.Document; + if (document is not Shell32.IShellFolderViewDual2 folderView) + { + continue; + } + + var selectedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var selectedItems = folderView.SelectedItems(); + if (selectedItems is null) + { + return null; + } + + try + { + for (var index = 0; index < selectedItems.Count; index++) + { + var item = selectedItems.Item(index); + if (item is null) + { + continue; + } + + try + { + if (TryNormalizePath(item.Path, out var selectedPath)) + { + selectedPaths.Add(selectedPath); + } + } + finally + { + Marshal.ReleaseComObject(item); + } + } + } + finally + { + Marshal.ReleaseComObject(selectedItems); + } + + var focusedItem = folderView.FocusedItem; + if (focusedItem is null) + { + return new SelectionSnapshot(selectedPaths, null); + } + + try + { + return new SelectionSnapshot( + selectedPaths, + TryNormalizePath(focusedItem.Path, out var focusedPath) ? focusedPath : null); + } + finally + { + Marshal.ReleaseComObject(focusedItem); + } + } + finally + { + ReleaseComObject(document); + ReleaseComObject(browser); + } + } + } + catch (COMException) + { + } + finally + { + ReleaseComObject(shellWindows); + ReleaseComObject(shellObject); + } + + return null; + } + + private static ViewSnapshot? TryGetView(IntPtr explorerWindow) + { + object? shellObject = null; + ShellWindows? shellWindows = null; + + try + { + var shellType = Type.GetTypeFromCLSID(ShellApplicationClassId, throwOnError: true)!; + shellObject = Activator.CreateInstance(shellType); + var shell = (Shell32.IShellDispatch2)shellObject!; + shellWindows = shell.Windows(); + foreach (IWebBrowserApp browser in shellWindows) + { + object? document = null; + try + { + if (browser.HWND != explorerWindow.ToInt64()) + { + continue; + } + + document = browser.Document; + if (document is Shell32.IShellFolderViewDual3 folderView) + { + return new ViewSnapshot((ViewMode)folderView.CurrentViewMode, folderView.IconSize); + } + } + finally + { + ReleaseComObject(document); + ReleaseComObject(browser); + } + } + } + catch (COMException) + { + } + finally + { + ReleaseComObject(shellWindows); + ReleaseComObject(shellObject); + } + + return null; + } + + private static bool TrySetView(IntPtr explorerWindow, ViewMode mode, int iconSize) + { + object? shellObject = null; + ShellWindows? shellWindows = null; + + try + { + var shellType = Type.GetTypeFromCLSID(ShellApplicationClassId, throwOnError: true)!; + shellObject = Activator.CreateInstance(shellType); + var shell = (Shell32.IShellDispatch2)shellObject!; + shellWindows = shell.Windows(); + foreach (IWebBrowserApp browser in shellWindows) + { + object? document = null; + try + { + if (browser.HWND != explorerWindow.ToInt64()) + { + continue; + } + + document = browser.Document; + if (document is not Shell32.IShellFolderViewDual3 folderView) + { + continue; + } + + folderView.CurrentViewMode = (uint)mode; + folderView.IconSize = iconSize; + return true; + } + finally + { + ReleaseComObject(document); + ReleaseComObject(browser); + } + } + } + catch (COMException) + { + } + finally + { + ReleaseComObject(shellWindows); + ReleaseComObject(shellObject); + } + + return false; + } + + private static bool TrySetSelection(IntPtr explorerWindow, IReadOnlySet<string> selectedPaths, string focusedPath) + { + object? shellObject = null; + ShellWindows? shellWindows = null; + + try + { + var shellType = Type.GetTypeFromCLSID(ShellApplicationClassId, throwOnError: true)!; + shellObject = Activator.CreateInstance(shellType); + var shell = (Shell32.IShellDispatch2)shellObject!; + shellWindows = shell.Windows(); + foreach (IWebBrowserApp browser in shellWindows) + { + object? document = null; + try + { + if (browser.HWND != explorerWindow.ToInt64()) + { + continue; + } + + document = browser.Document; + if (document is not Shell32.IShellFolderViewDual2 folderView) + { + continue; + } + + var folder = folderView.Folder; + if (folder is null) + { + return false; + } + + var folderItems = folder.Items(); + if (folderItems is null) + { + Marshal.ReleaseComObject(folder); + return false; + } + + var retainedItems = new List<Shell32.FolderItem>(); + try + { + var itemsByPath = new Dictionary<string, Shell32.FolderItem>(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < folderItems.Count; index++) + { + var item = folderItems.Item(index); + if (item is null) + { + continue; + } + + if (!TryNormalizePath(item.Path, out var normalizedPath)) + { + Marshal.ReleaseComObject(item); + continue; + } + + if (selectedPaths.Contains(normalizedPath)) + { + itemsByPath[normalizedPath] = item; + retainedItems.Add(item); + } + else + { + Marshal.ReleaseComObject(item); + } + } + + if (itemsByPath.Count != selectedPaths.Count) + { + return false; + } + + var orderedPaths = selectedPaths + .Where(path => !string.Equals(path, focusedPath, StringComparison.OrdinalIgnoreCase)) + .Append(focusedPath) + .ToList(); + + for (var index = 0; index < orderedPaths.Count; index++) + { + var path = orderedPaths[index]; + var flags = ShellViewSelect | ShellViewEnsureVisible; + if (index == 0) + { + flags |= ShellViewDeselectOthers; + } + + if (string.Equals(path, focusedPath, StringComparison.OrdinalIgnoreCase)) + { + flags |= ShellViewFocused; + } + + folderView.SelectItem(itemsByPath[path], flags); + } + + return true; + } + finally + { + foreach (var item in retainedItems) + { + Marshal.ReleaseComObject(item); + } + + Marshal.ReleaseComObject(folderItems); + Marshal.ReleaseComObject(folder); + } + } + finally + { + ReleaseComObject(document); + ReleaseComObject(browser); + } + } + } + catch (COMException) + { + } + finally + { + ReleaseComObject(shellWindows); + ReleaseComObject(shellObject); + } + + return false; + } + + private static string NormalizePath(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + + private static bool TryNormalizePath(string? path, out string normalizedPath) + { + normalizedPath = string.Empty; + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + normalizedPath = NormalizePath(path); + return true; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + private static void ReleaseComObject(object? value) + { + if (value is not null && Marshal.IsComObject(value)) + { + Marshal.ReleaseComObject(value); + } + } +} diff --git a/src/common/UITestAutomation.Next/ScreenRecording.cs b/src/common/UITestAutomation.Next/ScreenRecording.cs index 8fc8c7e2cb53..e3adfd6b81e1 100644 --- a/src/common/UITestAutomation.Next/ScreenRecording.cs +++ b/src/common/UITestAutomation.Next/ScreenRecording.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Globalization; +using System.Runtime.CompilerServices; using ScreenRecorderLib; namespace Microsoft.PowerToys.UITest.Next; @@ -43,11 +44,33 @@ public ScreenRecording(string outputDirectory) } /// <summary> - /// True when recording can be attempted. ScreenRecorderLib ships its native encoder in-package, - /// so there is nothing to locate at runtime; a missing prerequisite (e.g. Media Foundation on a - /// Windows N/Server SKU) is reported through <c>OnRecordingFailed</c> rather than here. + /// True when recording can be attempted, i.e. the native encoder assembly actually loads. + /// ScreenRecorderLib is a mixed-mode assembly importing VCRUNTIME140/MSVCP140, so a clean Windows + /// image without the Visual C++ redistributable cannot load it at all. /// </summary> - public bool IsAvailable => true; + public bool IsAvailable => UnavailableReason is null; + + /// <summary>Null when the native encoder can load; otherwise why it cannot.</summary> + public static string? UnavailableReason + { + get + { + try + { + ProbeNativeEncoder(); + return null; + } + catch (Exception ex) + { + return $"{ex.GetType().Name}: {ex.Message}"; + } + } + } + + // Kept out of line so the JIT resolves ScreenRecorderLib when this is called rather than when the + // caller is compiled - otherwise a missing native dependency throws past the caller's try/catch. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ProbeNativeEncoder() => _ = new RecorderOptions(); /// <summary>Path the encoded MP4 will be written to.</summary> public string OutputFilePath => outputFilePath; diff --git a/src/common/UITestAutomation.Next/Session.cs b/src/common/UITestAutomation.Next/Session.cs index 64b762355468..c61b6e837bdf 100644 --- a/src/common/UITestAutomation.Next/Session.cs +++ b/src/common/UITestAutomation.Next/Session.cs @@ -212,7 +212,19 @@ public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 5000) while (true) { - var matches = ExecuteSearch(by); + List<SearchHit> matches; + try + { + matches = ExecuteSearch(by); + } + catch (AssertFailedException ex) when ( + DateTime.UtcNow < deadline && + ex.Message.Contains("stale_element", StringComparison.OrdinalIgnoreCase)) + { + Thread.Sleep(200); + continue; + } + var typed = new List<T>(matches.Count); foreach (var m in matches) { @@ -303,6 +315,21 @@ public string Screenshot(string outputPath, Element? element = null, bool captur return outputPath; } + /// <summary> + /// Capture the session's visible DWM frame from the desktop, including composed content while + /// excluding the invisible resize border around top-level windows. + /// </summary> + public string ScreenshotVisibleWindow(string outputPath) + { + Assert.IsTrue(Scope == TargetScope.Window && WindowHandle != 0, "Visible-frame capture requires a window-scoped session."); + var windowHandle = new IntPtr(WindowHandle); + var foregroundFailure = $"HWND {WindowHandle} did not become foreground before screenshot capture. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}"; + Assert.IsTrue(WindowControl.WaitForForeground(windowHandle, timeoutMS: 5_000), foregroundFailure); + WindowHelper.CaptureVisibleWindow(windowHandle, outputPath); + return outputPath; + } + /// <summary>Non-asserting screenshot for cleanup / failure-artifact paths. Returns false on error.</summary> public bool TryScreenshot(string outputPath, Element? element = null, bool captureScreen = false) { diff --git a/src/common/UITestAutomation.Next/SettingsConfigHelper.cs b/src/common/UITestAutomation.Next/SettingsConfigHelper.cs index 228cdd5a43d7..56272d87584d 100644 --- a/src/common/UITestAutomation.Next/SettingsConfigHelper.cs +++ b/src/common/UITestAutomation.Next/SettingsConfigHelper.cs @@ -15,6 +15,43 @@ namespace Microsoft.PowerToys.UITest.Next; public static class SettingsConfigHelper { private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + private static readonly string[] KnownModuleNames = + [ + "AdvancedPaste", + "AlwaysOnTop", + "Awake", + "CmdNotFound", + "CmdPal", + "ColorPicker", + "CropAndLock", + "CursorWrap", + "EnvironmentVariables", + "FancyZones", + "File Explorer Preview", + "File Locksmith", + "FindMyMouse", + "GrabAndMove", + "Hosts", + "Image Resizer", + "Keyboard Manager", + "LightSwitch", + "Measure Tool", + "MouseHighlighter", + "MouseJump", + "MousePointerCrosshairs", + "MouseWithoutBorders", + "NewPlus", + "Peek", + "PowerDisplay", + "PowerRename", + "PowerToys Run", + "QuickAccent", + "RegistryPreview", + "Shortcut Guide", + "TextExtractor", + "Workspaces", + "ZoomIt", + ]; /// <summary>Root of the per-user PowerToys settings: <c>%LocalAppData%\Microsoft\PowerToys</c>.</summary> public static string PowerToysSettingsRoot => Path.Combine( @@ -24,10 +61,26 @@ public static class SettingsConfigHelper private static string GlobalSettingsPath => Path.Combine(PowerToysSettingsRoot, "settings.json"); + /// <summary> + /// Snapshot a module's <c>settings.json</c> and restore its exact bytes on disposal, deleting the + /// file instead when the test created it. Use before a suite mutates persistent profile settings. + /// </summary> + public static IDisposable PreserveModuleSettings(string moduleName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + return PreserveFile(Path.Combine(PowerToysSettingsRoot, moduleName, "settings.json")); + } + + internal static IDisposable PreserveFile(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + return new FileSnapshot(path); + } + /// <summary> /// Enable exactly the named modules in the global <c>settings.json</c> and disable every other - /// module already listed. Module names are the keys under <c>"enabled"</c> (e.g. "FancyZones", - /// "ColorPicker", "Peek"). Creates the file and keys when missing. + /// known or already-listed module. Module names are the keys under <c>"enabled"</c> + /// (e.g. "FancyZones", "ColorPicker", "Peek"). Creates the file and keys when missing. /// </summary> public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable) { @@ -38,25 +91,34 @@ public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnabl ? (JsonNode.Parse(File.ReadAllText(GlobalSettingsPath)) as JsonObject) ?? new JsonObject() : new JsonObject(); + ConfigureGlobalModuleSettings(root, modulesToEnable); + File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented)); + } + + internal static void ConfigureGlobalModuleSettings(JsonObject root, params string[] modulesToEnable) + { if (root["enabled"] is not JsonObject enabled) { enabled = new JsonObject(); root["enabled"] = enabled; } - // Flip every already-listed module based on membership (disables the rest). + var requestedModules = modulesToEnable.ToHashSet(StringComparer.Ordinal); + foreach (var key in enabled.Select(kv => kv.Key).ToList()) { - enabled[key] = modulesToEnable.Any(m => string.Equals(m, key, StringComparison.Ordinal)); + enabled[key] = requestedModules.Contains(key); } - // Ensure the requested modules are present and enabled even if not previously listed. - foreach (var module in modulesToEnable) + foreach (var module in KnownModuleNames) { - enabled[module] = true; + enabled[module] = requestedModules.Contains(module); } - File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented)); + foreach (var module in requestedModules) + { + enabled[module] = true; + } } /// <summary> @@ -135,4 +197,38 @@ public static void UpdateModuleSettings( File.WriteAllText(settingsPath, settings.ToJsonString(Indented)); } + + private sealed class FileSnapshot : IDisposable + { + private readonly string path; + private readonly bool existed; + private readonly byte[]? content; + private bool disposed; + + public FileSnapshot(string path) + { + this.path = path; + existed = File.Exists(path); + content = existed ? File.ReadAllBytes(path) : null; + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + if (existed) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, content!); + } + else + { + File.Delete(path); + } + } + } } diff --git a/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj b/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj index fd7277cd73ac..ab0f62f2383e 100644 --- a/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj +++ b/src/common/UITestAutomation.Next/UITestAutomation.Next.csproj @@ -21,9 +21,11 @@ <!-- Engine is winappcli (Microsoft.WinAppCli) — installed once per machine via `winget install Microsoft.winappcli`. We shell out to winapp.exe and parse its - JSON output. No managed dependency on the engine — only MSTest's attribute surface. + JSON output. There is no managed dependency on the engine. --> <PackageReference Include="MSTest.TestFramework" /> + <!-- Keep visual baseline comparison byte-for-byte compatible with the legacy UI-test harness. --> + <PackageReference Include="CoenM.ImageSharp.ImageHash" /> <!-- ScreenRecorderLib encodes the optional pipeline screen recording (ScreenRecording.cs) in realtime via native Media Foundation. Pipeline-only diagnostic; no PATH/FFmpeg setup. @@ -35,6 +37,31 @@ <PackageReference Include="ScreenRecorderLib" GeneratePathProperty="true" /> </ItemGroup> + <ItemGroup> + <InternalsVisibleTo Include="UITestAutomation.Next.UnitTests" /> + </ItemGroup> + + <ItemGroup> + <COMReference Include="Shell32"> + <VersionMinor>0</VersionMinor> + <VersionMajor>1</VersionMajor> + <Guid>50a7e9b0-70ef-11d1-b75a-00a0c90564fe</Guid> + <Lcid>0</Lcid> + <WrapperTool>tlbimp</WrapperTool> + <Isolated>false</Isolated> + <EmbedInteropTypes>true</EmbedInteropTypes> + </COMReference> + <COMReference Include="SHDocVw"> + <VersionMinor>1</VersionMinor> + <VersionMajor>1</VersionMajor> + <Guid>eab22ac0-30c1-11cf-a7eb-0000c05bae0b</Guid> + <Lcid>0</Lcid> + <WrapperTool>tlbimp</WrapperTool> + <Isolated>false</Isolated> + <EmbedInteropTypes>true</EmbedInteropTypes> + </COMReference> + </ItemGroup> + <!-- Map the build platform to ScreenRecorderLib's per-architecture folder (Win32 -> x86; x64, ARM64 and x86 match by name) and copy the resolved mixed-mode assembly to output as a diff --git a/src/common/UITestAutomation.Next/UITestBase.cs b/src/common/UITestAutomation.Next/UITestBase.cs index 789d5dd69f25..c2f420dbbfbd 100644 --- a/src/common/UITestAutomation.Next/UITestBase.cs +++ b/src/common/UITestAutomation.Next/UITestBase.cs @@ -213,7 +213,7 @@ public static void StopSharedScope() /// the PowerToys log files. Idempotent and fully tolerant — runs from both the <see cref="TestInit"/> /// failure path (where <c>[TestCleanup]</c> won't fire) and <see cref="TestCleanup"/>. /// </summary> - private async Task CaptureFailureArtifactsAsync() + protected async Task CaptureFailureArtifactsAsync() { if (artifactsCaptured) { @@ -222,6 +222,20 @@ private async Task CaptureFailureArtifactsAsync() artifactsCaptured = true; + try + { + var screenshotPath = Path.Combine( + TestContext.TestResultsDirectory ?? Path.GetTempPath(), + $"failure-{Guid.NewGuid():N}.png"); + if (ScreenCapture.TryCaptureDesktop(screenshotPath)) + { + TestContext.AddResultFile(screenshotPath); + } + } + catch + { + } + if (isInPipeline) { try @@ -246,6 +260,32 @@ private async Task CaptureFailureArtifactsAsync() } } + /// <summary> + /// Preserve and capture a failed test's terminal UI before derived cleanup closes its windows. + /// Call this at the beginning of a derived <c>[TestCleanup]</c>; passing tests return immediately. + /// </summary> + /// <param name="failureStateTail"> + /// Optional time to keep the failed UI visible in the recording before finalizing artifacts. + /// </param> + protected async Task CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan failureStateTail = default) + { + ArgumentOutOfRangeException.ThrowIfLessThan(failureStateTail, TimeSpan.Zero); + + var failed = TestContext.CurrentTestOutcome is + UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown; + if (!failed) + { + return; + } + + if (failureStateTail > TimeSpan.Zero) + { + await Task.Delay(failureStateTail); + } + + await CaptureFailureArtifactsAsync(); + } + /// <summary> /// Bring the desktop to a known state before launching: minimize every window, dismiss any /// lingering popup with <c>Esc</c>, kill the stale PowerToys processes in @@ -327,7 +367,7 @@ public Session RestartScope(string[]? enableModules = null) // ----- Pipeline diagnostics (CI only) --------------------------------------------------- - /// <summary>Start the FFmpeg screen recording. Best-effort.</summary> + /// <summary>Start the screen recording. Best-effort.</summary> private void StartPipelineCapture() { try @@ -339,17 +379,26 @@ private void StartPipelineCapture() try { screenRecording = new ScreenRecording(recordingDirectory); - if (screenRecording.IsAvailable) + + // Say why there is no video: an empty recordings folder otherwise looks like a lost + // artifact, and the usual cause (no Visual C++ redistributable on a clean image) is + // invisible from the test output. + var unavailable = ScreenRecording.UnavailableReason; + if (unavailable is null) { _ = screenRecording.StartRecordingAsync(); } else { + TestContext.WriteLine( + $"Screen recording disabled - the native encoder could not load ({unavailable}). " + + "Install the Visual C++ redistributable on this machine to capture video."); screenRecording = null; } } - catch + catch (Exception ex) { + TestContext.WriteLine($"Screen recording could not start: {ex.Message}"); screenRecording = null; } } diff --git a/src/common/UITestAutomation.Next/VisualAssert.cs b/src/common/UITestAutomation.Next/VisualAssert.cs new file mode 100644 index 000000000000..d6a437897a66 --- /dev/null +++ b/src/common/UITestAutomation.Next/VisualAssert.cs @@ -0,0 +1,119 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Drawing; +using CoenM.ImageHash; +using CoenM.ImageHash.HashAlgorithms; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest.Next; + +public static class VisualAssert +{ + private const int SimilarityThreshold = 95; + private const int VisualRetryTimeoutMS = 15_000; + private const int VisualRetryIntervalMS = 500; + + /// <summary> + /// Asserts that the current visual state of a session matches its embedded baseline image. + /// Visual validation runs only in the pipeline, matching the legacy harness behavior. + /// </summary> + [RequiresUnreferencedCode("This method uses reflection which may not be compatible with trimming.")] + public static void AreEqual(TestContext? testContext, Session session, string scenarioSubname = "") + { + if (!EnvironmentConfig.IsInPipeline) + { + Console.WriteLine("Skip visual validation in the local run."); + return; + } + + var callerMethod = new StackTrace().GetFrame(1)?.GetMethod(); + var callerName = callerMethod?.Name; + var callerClassName = callerMethod?.DeclaringType?.Name; + + if (string.IsNullOrEmpty(callerName) || string.IsNullOrEmpty(callerClassName)) + { + Assert.Fail("Unable to determine the caller method and class name."); + } + + scenarioSubname = string.IsNullOrWhiteSpace(scenarioSubname) + ? string.Join("_", callerClassName, callerName, EnvironmentConfig.Platform) + : string.Join("_", callerClassName, callerName, scenarioSubname.Trim(), EnvironmentConfig.Platform); + + var assembly = callerMethod!.DeclaringType!.Assembly; + var baselineImageResourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(name => Path.GetFileNameWithoutExtension(name).EndsWith(scenarioSubname, StringComparison.Ordinal)); + var testImagePath = GetTempFilePath(scenarioSubname, "test", ".png"); + + if (string.IsNullOrEmpty(baselineImageResourceName)) + { + session.ScreenshotVisibleWindow(testImagePath); + testContext?.AddResultFile(testImagePath); + Assert.Fail($"Baseline image for scenario {scenarioSubname} can't be found; test image saved to {testImagePath}."); + } + + var baselineImagePath = GetTempFilePath(scenarioSubname, "baseline", Path.GetExtension(baselineImageResourceName)); + using var stream = assembly.GetManifestResourceStream(baselineImageResourceName); + if (stream is null) + { + Assert.Fail($"Resource stream '{baselineImageResourceName}' is null."); + } + + using var baselineImage = new Bitmap(stream!); + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(VisualRetryTimeoutMS); + var similarity = 0d; + do + { + session.ScreenshotVisibleWindow(testImagePath); + using var testImage = new Bitmap(testImagePath); + similarity = CalculateSimilarity(baselineImage, testImage); + if (similarity >= SimilarityThreshold) + { + return; + } + + if (DateTime.UtcNow < deadline) + { + Thread.Sleep(VisualRetryIntervalMS); + } + } + while (DateTime.UtcNow < deadline); + + baselineImage.Save(baselineImagePath); + testContext?.AddResultFile(baselineImagePath); + testContext?.AddResultFile(testImagePath); + Assert.Fail( + $"Visual result for scenario {scenarioSubname} did not reach {SimilarityThreshold}% similarity " + + $"within {VisualRetryTimeoutMS / 1_000}s (last similarity: {similarity:F2}%). " + + $"Baseline: {baselineImagePath}; test image: {testImagePath}."); + } + + private static string GetTempFilePath(string scenario, string imageType, string extension) + { + var fileName = $"{scenario}_{imageType}{extension}"; + foreach (var invalidCharacter in Path.GetInvalidFileNameChars()) + { + fileName = fileName.Replace(invalidCharacter, '-'); + } + + return Path.Combine(Path.GetTempPath(), fileName); + } + + private static double CalculateSimilarity(Bitmap baselineImage, Bitmap testImage) + { + var hashAlgorithm = new AverageHash(); + using var baselineStream = new MemoryStream(); + using var testStream = new MemoryStream(); + baselineImage.Save(baselineStream, System.Drawing.Imaging.ImageFormat.Png); + testImage.Save(testStream, System.Drawing.Imaging.ImageFormat.Png); + baselineStream.Position = 0; + testStream.Position = 0; + + var baselineHash = hashAlgorithm.Hash(baselineStream); + var testHash = hashAlgorithm.Hash(testStream); + return CompareHash.Similarity(baselineHash, testHash); + } +} \ No newline at end of file diff --git a/src/common/UITestAutomation.Next/WaitHelper.cs b/src/common/UITestAutomation.Next/WaitHelper.cs new file mode 100644 index 000000000000..cbd3736cf2be --- /dev/null +++ b/src/common/UITestAutomation.Next/WaitHelper.cs @@ -0,0 +1,71 @@ +// 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. + +namespace Microsoft.PowerToys.UITest.Next; + +/// <summary>Polling helpers for UI state that must remain true across consecutive observations.</summary> +public static class WaitHelper +{ + /// <summary>The final state of a stable wait, including its last observation or retryable exception.</summary> + public readonly record struct StableWaitResult<T>(bool Succeeded, T? LastObservation, int ConsecutiveMatches, Exception? LastException = null); + + /// <summary> + /// Poll <paramref name="observe"/> until <paramref name="isMatch"/> is true for + /// <paramref name="requiredConsecutiveMatches"/> consecutive samples. A mismatch resets the + /// sample count and invokes the optional recovery action. Exceptions propagate unless + /// <paramref name="shouldRetryException"/> explicitly classifies them as transient. + /// </summary> + public static StableWaitResult<T> WaitForStable<T>( + Func<T?> observe, + Func<T?, bool> isMatch, + int timeoutMS, + int requiredConsecutiveMatches = 1, + int pollIntervalMS = 100, + Action<T?>? recover = null, + Func<Exception, bool>? shouldRetryException = null) + { + ArgumentNullException.ThrowIfNull(observe); + ArgumentNullException.ThrowIfNull(isMatch); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(timeoutMS); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requiredConsecutiveMatches); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pollIntervalMS); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var consecutiveMatches = 0; + T? lastObservation = default; + Exception? lastException = null; + + while (stopwatch.ElapsedMilliseconds < timeoutMS) + { + try + { + lastObservation = observe(); + if (isMatch(lastObservation)) + { + consecutiveMatches++; + if (consecutiveMatches >= requiredConsecutiveMatches) + { + return new StableWaitResult<T>(true, lastObservation, consecutiveMatches); + } + } + else + { + consecutiveMatches = 0; + recover?.Invoke(lastObservation); + } + + lastException = null; + } + catch (Exception ex) when (shouldRetryException?.Invoke(ex) == true) + { + consecutiveMatches = 0; + lastException = ex; + } + + Thread.Sleep(pollIntervalMS); + } + + return new StableWaitResult<T>(false, lastObservation, consecutiveMatches, lastException); + } +} diff --git a/src/common/UITestAutomation.Next/WinappCli.cs b/src/common/UITestAutomation.Next/WinappCli.cs index b21114e48f68..5e48b52f9b13 100644 --- a/src/common/UITestAutomation.Next/WinappCli.cs +++ b/src/common/UITestAutomation.Next/WinappCli.cs @@ -28,6 +28,8 @@ namespace Microsoft.PowerToys.UITest.Next; /// </remarks> public static class WinappCli { + internal const string InvokeTimeoutSecondsEnvironmentVariable = "WINAPP_CLI_INVOKE_TIMEOUT_SECONDS"; + /// <summary>Stable hint surfaced when the CLI is missing or fails — used in all error paths.</summary> public const string InstallHint = "winapp.exe not found. Install once with: winget install Microsoft.winappcli " + @@ -254,9 +256,17 @@ private static void KillStrayWinappProcesses(string[] args) /// command carries its own <c>-t</c>/<c>--timeout</c> wait in milliseconds (e.g. <c>wait-for</c>), the /// guard is extended past that wait plus a grace margin so a legitimate long wait isn't killed early. /// </summary> - private static TimeSpan ResolveInvokeTimeout(string[] args) + internal static TimeSpan ResolveInvokeTimeout(string[] args) { - var budget = DefaultInvokeTimeout; + var configuredSeconds = Environment.GetEnvironmentVariable(InvokeTimeoutSecondsEnvironmentVariable); + var budget = int.TryParse( + configuredSeconds, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var seconds) && + seconds is > 0 and <= 3_600 + ? TimeSpan.FromSeconds(seconds) + : DefaultInvokeTimeout; for (var i = 0; i < args.Length - 1; i++) { if ((string.Equals(args[i], "-t", StringComparison.Ordinal) || diff --git a/src/common/UITestAutomation.Next/WindowControl.cs b/src/common/UITestAutomation.Next/WindowControl.cs index 546b0f1efe45..d99094efa5d6 100644 --- a/src/common/UITestAutomation.Next/WindowControl.cs +++ b/src/common/UITestAutomation.Next/WindowControl.cs @@ -42,6 +42,10 @@ public static class WindowControl [DllImport("user32.dll", SetLastError = true)] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetGUIThreadInfo(uint idThread, ref GUITHREADINFO lpgui); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern int GetClassNameW(IntPtr hWnd, [Out] char[] lpClassName, int nMaxCount); @@ -77,6 +81,7 @@ public static class WindowControl private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); private const uint WM_CLOSE = 0x0010; + private const uint WM_CONTEXTMENU = 0x007B; private const int SW_RESTORE = 9; [StructLayout(LayoutKind.Sequential)] @@ -88,12 +93,29 @@ private struct RECT public int Bottom; } + [StructLayout(LayoutKind.Sequential)] + private struct GUITHREADINFO + { + public int Size; + public uint Flags; + public IntPtr ActiveWindow; + public IntPtr FocusedWindow; + public IntPtr CaptureWindow; + public IntPtr MenuOwnerWindow; + public IntPtr MoveSizeWindow; + public IntPtr CaretWindow; + public RECT CaretRectangle; + } + /// <summary> /// A top-level window discovered by <see cref="EnumerateProcessWindows"/> / <see cref="EnumerateAllWindows"/>: /// its native handle, owning process id, window class, title, size in physical pixels, and visibility. /// </summary> public readonly record struct ProcessWindow(IntPtr Hwnd, int ProcessId, string ClassName, string Title, int Width, int Height, bool IsVisible); + /// <summary>Diagnostic details for the current foreground window.</summary> + public readonly record struct ForegroundWindowInfo(IntPtr Hwnd, int ProcessId, string ProcessName, string ClassName, string Title, bool? IsElevated); + /// <summary> /// Enumerate the top-level windows owned by any process in <paramref name="processIds"/> using the /// pure Win32 <c>EnumWindows</c> API. Unlike winappcli's UI-Automation-backed <c>list-windows</c>, @@ -319,6 +341,83 @@ public static bool TryBringToForeground(IntPtr hwnd) } } + /// <summary>Return the current foreground window handle.</summary> + public static IntPtr GetForegroundWindowHandle() => GetForegroundWindow(); + + /// <summary>Open the context menu owned by the control that currently has focus in a foreground window.</summary> + public static bool TryOpenContextMenuForFocusedControl(IntPtr ownerWindow) + { + if (ownerWindow == IntPtr.Zero || !IsWindow(ownerWindow) || !TryBringToForeground(ownerWindow)) + { + return false; + } + + var threadId = GetWindowThreadProcessId(ownerWindow, out _); + var threadInfo = new GUITHREADINFO { Size = Marshal.SizeOf<GUITHREADINFO>() }; + var targetWindow = GetGUIThreadInfo(threadId, ref threadInfo) && threadInfo.FocusedWindow != IntPtr.Zero + ? threadInfo.FocusedWindow + : ownerWindow; + return PostMessageW(targetWindow, WM_CONTEXTMENU, targetWindow, new IntPtr(-1)); + } + + /// <summary>Return process, class, title, and elevation details for the current foreground HWND.</summary> + public static ForegroundWindowInfo GetForegroundWindowInfo() + { + var hwnd = GetForegroundWindow(); + if (hwnd == IntPtr.Zero) + { + return new ForegroundWindowInfo(IntPtr.Zero, 0, string.Empty, string.Empty, string.Empty, null); + } + + var foregroundThreadId = GetWindowThreadProcessId(hwnd, out var processId); + if (foregroundThreadId == 0) + { + processId = 0; + } + + var processName = string.Empty; + if (processId != 0) + { + try + { + using var process = Process.GetProcessById((int)processId); + processName = process.ProcessName; + } + catch + { + } + } + + return new ForegroundWindowInfo( + hwnd, + (int)processId, + processName, + GetWindowClassName(hwnd), + GetWindowTitle(hwnd), + processId == 0 ? null : ElevationHelper.IsProcessElevated((int)processId)); + } + + /// <summary>Bring an HWND forward until it owns foreground for the requested consecutive samples.</summary> + public static bool WaitForForeground( + IntPtr hwnd, + int timeoutMS = 5_000, + int requiredConsecutiveMatches = 1, + int pollIntervalMS = 100) + { + if (hwnd == IntPtr.Zero) + { + return false; + } + + return WaitHelper.WaitForStable( + observe: GetForegroundWindow, + isMatch: foreground => foreground == hwnd, + timeoutMS: timeoutMS, + requiredConsecutiveMatches: requiredConsecutiveMatches, + pollIntervalMS: pollIntervalMS, + recover: _ => TryBringToForeground(hwnd)).Succeeded; + } + public static bool TryFocusByApp(string appNameOrPid) { try @@ -432,6 +531,79 @@ public static bool TryKillProcessByName(string exactProcessName) } } + /// <summary> + /// Force-terminate every exact-name process tree and wait until no matching process remains. + /// Unlike <see cref="TryKillProcessByName"/>, this also treats an already-absent process as success. + /// </summary> + public static bool TryKillProcessTreeByNameAndWait(string exactProcessName, int timeoutMS = 10_000) + { + ArgumentException.ThrowIfNullOrWhiteSpace(exactProcessName); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(timeoutMS); + + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (DateTime.UtcNow < deadline) + { + var processes = Process.GetProcessesByName(exactProcessName); + if (processes.Length == 0) + { + return true; + } + + try + { + foreach (var process in processes) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + } + } + + foreach (var process in processes) + { + try + { + var remainingMS = Math.Max(0, (int)(deadline - DateTime.UtcNow).TotalMilliseconds); + if (!process.WaitForExit(remainingMS)) + { + return false; + } + } + catch + { + return false; + } + } + } + finally + { + foreach (var process in processes) + { + process.Dispose(); + } + } + } + + var remainingProcesses = Process.GetProcessesByName(exactProcessName); + try + { + return remainingProcesses.Length == 0; + } + finally + { + foreach (var process in remainingProcesses) + { + process.Dispose(); + } + } + } + private static void TryCloseHwnd(long hwnd) { try diff --git a/src/common/UITestAutomation.Next/WindowHelper.cs b/src/common/UITestAutomation.Next/WindowHelper.cs index e192261e7af5..088648e46ab4 100644 --- a/src/common/UITestAutomation.Next/WindowHelper.cs +++ b/src/common/UITestAutomation.Next/WindowHelper.cs @@ -49,11 +49,15 @@ private struct RECT } private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOZORDER = 0x0004; private const uint SWP_NOACTIVATE = 0x0010; + private const int GWL_EXSTYLE = -20; + private const long WS_EX_TOPMOST = 0x00000008L; private const int SM_CXSCREEN = 0; private const int SM_CYSCREEN = 1; private const int SW_MAXIMIZE = 3; + private const int DwmExtendedFrameBoundsAttribute = 9; [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] @@ -63,6 +67,9 @@ private struct RECT [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] + private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); @@ -79,6 +86,12 @@ private struct RECT [DllImport("gdi32.dll")] private static extern uint GetPixel(IntPtr hdc, int x, int y); + [DllImport("dwmapi.dll")] + private static extern int DwmGetWindowAttribute(IntPtr hWnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); + + [DllImport("dwmapi.dll")] + private static extern int DwmFlush(); + /// <summary>True when any UIA-visible window's title contains <paramref name="titleContains"/> (CLI-based).</summary> public static bool IsWindowOpen(string titleContains) => WindowsFinder.ListAll().Any(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase)); @@ -115,6 +128,10 @@ public static void SetWindowSize(IntPtr hWnd, WindowSize size) public static void SetMainWindowSize(IntPtr hWnd, int width, int height) => SetWindowPos(hWnd, IntPtr.Zero, 0, 0, width, height, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + /// <summary>Move a window to explicit screen coordinates while preserving its current size.</summary> + public static void MoveWindow(IntPtr hWnd, int x, int y) => + SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + /// <summary> /// Maximize a window so it fills the monitor work area and is fully on-screen. Used as the default /// window state for tests so a module's restored (possibly small or off-screen) last window rect @@ -133,6 +150,53 @@ public static (int Left, int Top, int Right, int Bottom) GetWindowBounds(IntPtr return (0, 0, 0, 0); } + /// <summary> + /// Capture the visible DWM frame from the screen. Unlike PrintWindow, this includes composed + /// WinUI/WebView content; unlike a raw GetWindowRect capture, it excludes invisible resize borders. + /// </summary> + public static void CaptureVisibleWindow(IntPtr hWnd, string outputPath) + { + var result = DwmGetWindowAttribute( + hWnd, + DwmExtendedFrameBoundsAttribute, + out var bounds, + Marshal.SizeOf<RECT>()); + if (result != 0 || bounds.Right <= bounds.Left || bounds.Bottom <= bounds.Top) + { + throw new InvalidOperationException($"Unable to read visible frame bounds for HWND {hWnd} (HRESULT 0x{result:X8})."); + } + + var wasTopmost = (GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64() & WS_EX_TOPMOST) != 0; + var noMoveOrResize = SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE; + var topmost = new IntPtr(-1); + var notTopmost = new IntPtr(-2); + + try + { + SetWindowPos(hWnd, topmost, 0, 0, 0, 0, noMoveOrResize); + DwmFlush(); + + using var bitmap = new Bitmap(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top); + using var graphics = Graphics.FromImage(bitmap); + graphics.CopyFromScreen( + bounds.Left, + bounds.Top, + 0, + 0, + bitmap.Size, + CopyPixelOperation.SourceCopy); + bitmap.Save(outputPath, System.Drawing.Imaging.ImageFormat.Png); + } + finally + { + if (!wasTopmost) + { + SetWindowPos(hWnd, notTopmost, 0, 0, 0, 0, noMoveOrResize); + DwmFlush(); + } + } + } + /// <summary>Center point of the window in screen pixels.</summary> public static (int CenterX, int CenterY) GetWindowCenter(IntPtr hWnd) { diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml index 1959eaf8205b..3dffe8c46c97 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml @@ -61,6 +61,7 @@ Orientation="Horizontal" Spacing="8"> <Button + AutomationProperties.AutomationId="ReloadBtn" Command="{Binding LoadProcessesCommand}" Content="{ui:FontIcon Glyph=&#xe72c;, FontSize=16}" diff --git a/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/ExplorerHelper.cs b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/ExplorerHelper.cs new file mode 100644 index 000000000000..91338a625da1 --- /dev/null +++ b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/ExplorerHelper.cs @@ -0,0 +1,398 @@ +// 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 Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.FileLocksmith.UITests; + +/// <summary>Which Explorer context-menu surface a probe should drive.</summary> +internal enum ContextMenuTier +{ + /// <summary>Whatever the OS shows on a plain right-click: tier-1 on Windows 11, classic on Windows 10.</summary> + Default, + + /// <summary>The classic <c>#32768</c> menu, reached through "Show more options" on Windows 11.</summary> + Classic, +} + +/// <summary>One stable look at an open context menu.</summary> +internal sealed record MenuObservation(bool IsOpen, bool HasCommand, bool HasSibling); + +/// <summary> +/// Opens Explorer, establishes an exact Shell selection, and drives either context-menu tier. The +/// selection is re-established on every attempt because a slow agent re-renders the view +/// asynchronously after a module toggles or the shell restarts. +/// </summary> +internal static class ExplorerHelper +{ + public const string ClassicMenuClassName = "#32768"; + public const string ModernMenuClassName = "Microsoft.UI.Content.PopupWindowSiteBridge"; + + private const string ExplorerProcessName = "explorer"; + private const string ShowMoreOptionsCaption = "Show more options"; + private const int ExplorerTimeoutMS = 30_000; + private const int MenuSurfaceTimeoutMS = 25_000; + + private static bool shellRestarted; + + public static bool IsWindows11OrNewer => Environment.OSVersion.Version.Build >= 22_000; + + public static Session OpenFolder(string folderPath) => OpenLocation($"/n,\"{folderPath}\"", folderPath); + + /// <summary>Open "This PC", the only view that exposes drive roots as selectable Shell items.</summary> + public static Session OpenThisPc() => OpenLocation("shell:MyComputerFolder", "This PC"); + + public static bool CloseFileWindows() => + WindowControl.TryCloseByApp(ExplorerProcessName, IsExplorerFileWindow, timeoutMS: 10_000); + + /// <summary> + /// Both handlers register at module-enable time — the classic registry-COM one always, the modern + /// sparse-MSIX package on signed builds. An Explorer that was already running only surfaces them + /// after the shell restarts, so do it exactly once per test run. + /// </summary> + public static void EnsureShellRestartedOnce() + { + if (shellRestarted) + { + return; + } + + shellRestarted = true; + Thread.Sleep(3_000); + + var previousProcessIds = Process.GetProcessesByName(ExplorerProcessName) + .Select(process => + { + var id = process.Id; + process.Dispose(); + return id; + }) + .ToHashSet(); + + // Only explorer.exe: Kill(entireProcessTree) would also take down anything launched from it. + WindowControl.TryKillProcessByName(ExplorerProcessName); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (DateTime.UtcNow < deadline) + { + var current = Process.GetProcessesByName(ExplorerProcessName); + var hasFreshShell = current.Any(process => !previousProcessIds.Contains(process.Id)); + foreach (var process in current) + { + process.Dispose(); + } + + if (hasFreshShell) + { + break; + } + + Thread.Sleep(500); + } + + Thread.Sleep(2_000); + } + + /// <summary> + /// Poll the requested menu tier until it reports <paramref name="expectedCommand"/> across + /// consecutive samples, re-selecting (and if needed reopening) the view on every attempt. + /// </summary> + public static (bool Succeeded, MenuObservation Last, Session Explorer) ProbeCommand( + Session explorer, + Func<Session> reopenExplorer, + string[] selection, + ContextMenuTier tier, + string commandCaption, + bool expectedCommand, + string? siblingCaption, + TestContext testContext, + int deadlineSeconds = 120) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(deadlineSeconds); + var last = new MenuObservation(false, false, false); + var selectionFailures = 0; + + do + { + KeyboardHelper.SendKeys(Key.Esc); + + var selected = TrySelectStable(explorer, selection, timeoutMS: 12_000); + if (selected is null) + { + testContext.WriteLine( + $"Explorer selection did not settle for [{string.Join(", ", selection)}]. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + if (++selectionFailures >= 2) + { + selectionFailures = 0; + explorer = reopenExplorer(); + } + + Thread.Sleep(300); + continue; + } + + selectionFailures = 0; + explorer = selected; + + var menu = OpenMenu(explorer, tier); + if (menu is null) + { + Thread.Sleep(300); + continue; + } + + var stable = WaitHelper.WaitForStable( + observe: () => Observe(menu, commandCaption, siblingCaption), + isMatch: observation => observation is not null && + observation.IsOpen && + observation.HasCommand == expectedCommand && + (siblingCaption is null || observation.HasSibling), + timeoutMS: 8_000, + requiredConsecutiveMatches: 4, + pollIntervalMS: 250); + last = stable.LastObservation ?? last; + KeyboardHelper.SendKeys(Key.Esc); + + if (stable.Succeeded) + { + return (true, last, explorer); + } + + Thread.Sleep(300); + } + while (DateTime.UtcNow < deadline); + + return (false, last, explorer); + } + + /// <summary>Open the requested tier and invoke <paramref name="commandCaption"/> on it.</summary> + public static Session InvokeCommand( + Session explorer, + Func<Session> reopenExplorer, + string[] selection, + ContextMenuTier tier, + string commandCaption, + int deadlineSeconds = 120) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(deadlineSeconds); + Session? menu = null; + Element? command = null; + var selectionFailures = 0; + + do + { + var selected = TrySelectStable(explorer, selection, timeoutMS: 12_000); + if (selected is null) + { + if (++selectionFailures >= 2) + { + selectionFailures = 0; + explorer = reopenExplorer(); + } + + Thread.Sleep(300); + continue; + } + + selectionFailures = 0; + explorer = selected; + + menu = OpenMenu(explorer, tier); + if (menu is not null) + { + command = FindVisibleMenuItem(menu, commandCaption, timeoutMS: 5_000); + if (command is not null) + { + break; + } + } + + KeyboardHelper.SendKeys(Key.Esc); + Thread.Sleep(300); + } + while (DateTime.UtcNow < deadline); + + Assert.IsNotNull(menu, "Explorer did not open the expected context-menu surface."); + Assert.IsNotNull( + command, + $"Explorer did not show the '{commandCaption}' command for [{string.Join(", ", selection)}]."); + command!.Invoke(msPostAction: 300); + return explorer; + } + + /// <summary> + /// Non-throwing selection: re-establishes an exact, stable Shell selection and returns the live + /// session, handling an Explorer window that was replaced mid-render. + /// </summary> + public static Session? TrySelectStable(Session explorer, string[] paths, int timeoutMS) + { + if (ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), paths, paths[0], timeoutMS, requiredConsecutiveMatches: 4).Succeeded) + { + return explorer; + } + + var replacement = FindReplacementExplorer(explorer); + if (replacement is not null && + ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(replacement.WindowHandle), paths, paths[0], timeoutMS, requiredConsecutiveMatches: 4).Succeeded) + { + return replacement; + } + + return null; + } + + public static Element? FindVisibleMenuItem(Session menu, string caption, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + var item = menu.FindAll<Element>(By.Name(caption), timeoutMS: 250) + .FirstOrDefault(element => + element.Name.Contains(caption, StringComparison.OrdinalIgnoreCase) && + element.ControlType.Equals("MenuItem", StringComparison.OrdinalIgnoreCase) && + element.Width > 0 && + element.Height > 0); + if (item is not null) + { + return item; + } + + Thread.Sleep(100); + } + while (DateTime.UtcNow < deadline); + + return null; + } + + private static Session OpenLocation(string arguments, string diagnosticName) + { + EnsureShellRestartedOnce(); + CloseFileWindows(); + + var existingHandles = WindowsFinder.ListByApp(ExplorerProcessName) + .Where(IsExplorerFileWindow) + .Select(window => window.Hwnd) + .ToHashSet(); + + using var explorerLaunch = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = arguments, + UseShellExecute = true, + }); + + var explorer = WindowsFinder.WaitForWindowByApp( + ExplorerProcessName, + window => IsExplorerFileWindow(window) && !existingHandles.Contains(window.Hwnd), + timeoutMS: ExplorerTimeoutMS); + Assert.IsNotNull(explorer, $"Explorer did not open '{diagnosticName}'."); + + EnsureForeground(explorer!); + return explorer!; + } + + private static Session? OpenMenu(Session explorer, ContextMenuTier tier) + { + EnsureForeground(explorer); + KeyboardHelper.SendKeys(Key.Esc); + + if (!WindowControl.TryOpenContextMenuForFocusedControl(new IntPtr(explorer.WindowHandle))) + { + return null; + } + + var firstSurface = WaitForMenuSurface( + IsWindows11OrNewer ? ModernMenuClassName : ClassicMenuClassName, + MenuSurfaceTimeoutMS); + if (firstSurface is null || tier == ContextMenuTier.Default || !IsWindows11OrNewer) + { + return firstSurface; + } + + // Windows 11 only: the classic menu lives one level down, behind "Show more options". + var showMoreOptions = FindVisibleMenuItem(firstSurface, ShowMoreOptionsCaption, timeoutMS: 5_000); + if (showMoreOptions is null) + { + return null; + } + + try + { + showMoreOptions.Invoke(msPostAction: 300); + } + catch (Exception) + { + // The popup can vanish between finding and invoking it; let the caller reopen the menu. + return null; + } + + return WaitForMenuSurface(ClassicMenuClassName, MenuSurfaceTimeoutMS); + } + + private static Session? WaitForMenuSurface(string className, int timeoutMS) => + WindowsFinder.WaitForWindow( + window => className == ClassicMenuClassName + ? window.ClassName.Equals(className, StringComparison.OrdinalIgnoreCase) + : window.ClassName.Contains(className, StringComparison.OrdinalIgnoreCase), + timeoutMS: timeoutMS, + pollIntervalMS: 100); + + private static MenuObservation Observe(Session menu, string commandCaption, string? siblingCaption) + { + var menuReady = menu.WindowHandle != 0 && + WindowsFinder.ListAll().Any(window => window.Hwnd == menu.WindowHandle); + if (!menuReady) + { + return new MenuObservation(false, false, false); + } + + try + { + return new MenuObservation( + true, + FindVisibleMenuItem(menu, commandCaption, timeoutMS: 250) is not null, + siblingCaption is null || FindVisibleMenuItem(menu, siblingCaption, timeoutMS: 250) is not null); + } + catch (Exception) + { + // winappcli reports the popup's HWND as gone mid-query; treat it as not-yet-stable. + return new MenuObservation(false, false, false); + } + } + + private static Session? FindReplacementExplorer(Session explorer) + { + var foregroundWindow = WindowControl.GetForegroundWindowHandle().ToInt64(); + var replacement = WindowsFinder.ListByApp(ExplorerProcessName) + .Where(IsExplorerFileWindow) + .Where(window => window.Hwnd != explorer.WindowHandle) + .OrderByDescending(window => window.Hwnd == foregroundWindow) + .FirstOrDefault(); + if (replacement is null) + { + return null; + } + + return WindowsFinder.WaitForWindow( + window => window.Hwnd == replacement.Hwnd, + timeoutMS: 2_000, + pollIntervalMS: 100); + } + + private static void EnsureForeground(Session explorer) => Assert.IsTrue( + WindowControl.WaitForForeground( + new IntPtr(explorer.WindowHandle), + ExplorerTimeoutMS, + requiredConsecutiveMatches: 3), + $"Explorer HWND {explorer.WindowHandle} was not the stable foreground window. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + + private static bool IsExplorerFileWindow(WindowsFinder.WindowInfo window) => + window.ClassName.Equals("CabinetWClass", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmith.UITests.csproj b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmith.UITests.csproj new file mode 100644 index 000000000000..6ce8f6496e24 --- /dev/null +++ b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmith.UITests.csproj @@ -0,0 +1,33 @@ +<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.FileLocksmith.UITests</RootNamespace> + <AssemblyName>FileLocksmith.UITests</AssemblyName> + <ApplicationManifest>app.manifest</ApplicationManifest> + + <IsTestingPlatformApplication>true</IsTestingPlatformApplication> + <EnableMSTestRunner>true</EnableMSTestRunner> + <GenerateDocumentationFile>false</GenerateDocumentationFile> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\FileLocksmith.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithContextMenuTests.cs b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithContextMenuTests.cs new file mode 100644 index 000000000000..5cd678be0ef4 --- /dev/null +++ b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithContextMenuTests.cs @@ -0,0 +1,384 @@ +// 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; +using Microsoft.Win32; + +namespace Microsoft.PowerToys.FileLocksmith.UITests; + +/// <summary> +/// Explorer-driven coverage of the File Locksmith release checklist: the +/// "Unlock with File Locksmith" command on a file, a folder and a drive, and the fact that the +/// Settings toggle gates it out of both context-menu tiers without breaking the menu itself. +/// </summary> +[TestClass] +[DoNotParallelize] +public sealed class FileLocksmithContextMenuTests : UITestBase +{ + private const string ClassicHandlerKeyPath = + @"Software\Classes\AllFileSystemObjects\ShellEx\ContextMenuHandlers\FileLocksmithExt"; + + private const string ModernPackageName = "FileLocksmithContextMenu"; + + private static readonly string[] ShellIntegrationModules = + { + FileLocksmithConstants.ModuleName, + FileLocksmithConstants.PowerRenameModuleName, + }; + + private readonly List<LockingProcessFixture> fixtures = new(); + + public FileLocksmithContextMenuTests() + : base(PowerToysModule.PowerToysSettings, enableModules: ShellIntegrationModules) + { + } + + protected override bool ReuseScopeAcrossTests => true; + + protected override IReadOnlyList<string> StaleProcessNames { get; } = new[] + { + "PowerToys", + "PowerToys.Settings", + FileLocksmithConstants.UiProcessName, + }; + + [TestInitialize] + public void PrepareTest() + { + Assert.IsTrue(FileLocksmithUi.Close(), "A stale File Locksmith window could not be closed before the test."); + Assert.IsTrue(ExplorerHelper.CloseFileWindows(), "Stale Explorer file windows could not be closed before the test."); + + // Both handlers register when the runner enables the module; give that a moment to land. + WaitUntil(() => DefaultTierRegistered() || ClassicHandlerRegistered(), timeoutMS: 30_000); + } + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); + FileLocksmithUi.Close(); + ExplorerHelper.CloseFileWindows(); + + foreach (var fixture in fixtures) + { + fixture.Dispose(); + } + + fixtures.Clear(); + } + + /// <summary> + /// Checklist: right-click an executable that is currently running twice, confirm + /// "Unlock with File Locksmith" is present, and that invoking it opens the File Locksmith window + /// listing one row (with an "End task" button) per process holding the file. + /// </summary> + [TestMethod("FileLocksmith.ContextMenu.LaunchOnLockedFile")] + [TestCategory("File Locksmith")] + public void ContextMenuLaunchesFileLocksmithForLockedFile() + { + RequireDefaultTier(); + var fixture = CreateFixture(); + fixture.Start(count: 2); + + var explorer = ExplorerHelper.OpenFolder(fixture.TargetFolder); + ExplorerHelper.InvokeCommand( + explorer, + () => ExplorerHelper.OpenFolder(fixture.TargetFolder), + new[] { fixture.TargetPath }, + ContextMenuTier.Default, + FileLocksmithConstants.ContextMenuCaption); + + var ui = FileLocksmithUi.WaitForWindow(loadTimeoutMS: 60_000); + Assert.AreEqual( + FileLocksmithConstants.WindowTitle, + FileLocksmithUi.CurrentWindowTitle(), + "File Locksmith launched from the context menu was not the expected non-elevated window."); + Assert.IsTrue( + FileLocksmithUi.WaitForRowCount(ui, expected: 2, timeoutMS: 30_000), + $"File Locksmith listed {FileLocksmithUi.CountRows(ui)} row(s) for a file locked by 2 processes."); + Assert.IsTrue( + FileLocksmithUi.HasProcessRow(ui, LockingProcessFixture.LockerFileName), + $"The listed rows were not headed by '{LockingProcessFixture.LockerFileName}'."); + Assert.AreEqual( + 2, + FileLocksmithUi.CountRows(ui), + "Each listed process must offer its own End task button."); + } + + /// <summary> + /// Checklist: right-click the directory containing the executable and confirm the command is + /// present and lists the process(es) found recursively inside that directory tree. + /// </summary> + [TestMethod("FileLocksmith.ContextMenu.Directory")] + [TestCategory("File Locksmith")] + public void ContextMenuScansDirectoryRecursively() + { + RequireDefaultTier(); + + // The locked file lives one level below the folder under test, so only a recursive scan finds it. + var fixture = CreateFixture(targetSubFolder: "nested"); + fixture.Start(); + + var parentFolder = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(fixture.RootFolder))!; + var explorer = ExplorerHelper.OpenFolder(parentFolder); + ExplorerHelper.InvokeCommand( + explorer, + () => ExplorerHelper.OpenFolder(parentFolder), + new[] { fixture.RootFolder }, + ContextMenuTier.Default, + FileLocksmithConstants.ContextMenuCaption); + + var ui = FileLocksmithUi.WaitForWindow(loadTimeoutMS: 60_000); + Assert.IsTrue( + FileLocksmithUi.WaitForRowCount(ui, expected: 1, timeoutMS: 30_000), + "File Locksmith did not report the process locking a file nested inside the selected directory."); + Assert.IsTrue( + FileLocksmithUi.HasProcessRow(ui, LockingProcessFixture.LockerFileName), + $"The listed row was not headed by '{LockingProcessFixture.LockerFileName}'."); + } + + /// <summary> + /// Checklist: right-click the drive holding the executable and confirm the command is present. + /// The sparse package only registers <c>Directory</c> and <c>*</c> item types, so on Windows 11 + /// the drive command lives in the classic ("Show more options") menu. + /// </summary> + [TestMethod("FileLocksmith.ContextMenu.DriveRoot")] + [TestCategory("File Locksmith")] + public void ContextMenuIsAvailableOnDriveRoot() + { + RequireClassicTier(); + var fixture = CreateFixture(); + fixture.Start(); + + var explorer = ExplorerHelper.OpenThisPc(); + var probe = ExplorerHelper.ProbeCommand( + explorer, + ExplorerHelper.OpenThisPc, + new[] { fixture.DriveRoot }, + ContextMenuTier.Classic, + FileLocksmithConstants.ContextMenuCaption, + expectedCommand: true, + siblingCaption: null, + TestContext); + + Assert.IsTrue(probe.Last.IsOpen, "The classic context menu for the drive did not become ready."); + Assert.IsTrue( + probe.Succeeded, + $"The classic context menu for drive '{fixture.DriveRoot}' did not show " + + $"'{FileLocksmithConstants.ContextMenuCaption}'."); + } + + /// <summary> + /// Checklist: disabling File Locksmith removes its command from the tier-1 menu and from + /// "Show more options", while a sibling PowerToys command stays put — proving the menu still + /// renders and only File Locksmith was gated out. Re-enabling brings it back in both menus. + /// </summary> + [TestMethod("FileLocksmith.ContextMenu.EnabledState")] + [TestCategory("File Locksmith")] + public void ContextMenuTracksModuleEnabledState() + { + RequireDefaultTier(); + var settings = NavigateToFileLocksmithSettings(); + var toggle = settings.Find<ToggleSwitch>(By.Name(FileLocksmithConstants.ModuleName)); + Assert.IsTrue(toggle.IsOn, "File Locksmith did not start from the deterministic enabled baseline."); + + var fixture = CreateFixture(); + var folder = fixture.TargetFolder; + var selection = new[] { fixture.TargetPath }; + var tiers = ClassicHandlerRegistered() && ExplorerHelper.IsWindows11OrNewer + ? new[] { ContextMenuTier.Default, ContextMenuTier.Classic } + : new[] { ContextMenuTier.Default }; + + try + { + foreach (var tier in tiers) + { + AssertCommandPresence(folder, selection, tier, expected: true); + } + + toggle = SetModuleEnabled(toggle, false); + foreach (var tier in tiers) + { + AssertCommandPresence(folder, selection, tier, expected: false); + } + + toggle = SetModuleEnabled(toggle, true); + foreach (var tier in tiers) + { + AssertCommandPresence(folder, selection, tier, expected: true); + } + } + finally + { + try + { + SetModuleEnabled(toggle, true); + } + catch (Exception ex) + { + TestContext.WriteLine($"Restoring the File Locksmith toggle failed; restarting the scope. {ex.Message}"); + RestartScope(ShellIntegrationModules); + } + } + } + + private static bool ClassicHandlerRegistered() + { + using var key = Registry.CurrentUser.OpenSubKey(ClassicHandlerKeyPath); + return key is not null; + } + + /// <summary>The surface a plain right-click shows: tier-1 on Windows 11, classic on Windows 10.</summary> + private static bool DefaultTierRegistered() => + ExplorerHelper.IsWindows11OrNewer ? ModernPackageRegistered() : ClassicHandlerRegistered(); + + private static void RequireDefaultTier() + { + if (DefaultTierRegistered()) + { + return; + } + + Assert.Inconclusive( + ExplorerHelper.IsWindows11OrNewer + ? "The FileLocksmithContextMenu sparse package is not registered, so no Windows 11 tier-1 " + + "command can appear. Unsigned builds fail to register it (0x800B0100) — sign the .msix and " + + "trust the signer (see .pipelines/signSparsePackages.ps1)." + : "The classic File Locksmith context-menu handler is not registered. It is compiled out of " + + "Debug builds — build Release, or define ENABLE_REGISTRATION for FileLocksmithExt."); + } + + private static void RequireClassicTier() + { + if (ClassicHandlerRegistered()) + { + return; + } + + Assert.Inconclusive( + "The classic (registry-COM) File Locksmith handler is not registered, so no drive command can " + + "exist. It is compiled out of Debug builds — build Release, or define ENABLE_REGISTRATION."); + } + + private static bool WaitUntil(Func<bool> condition, int timeoutMS, int pollIntervalMS = 1_000) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + if (condition()) + { + return true; + } + + Thread.Sleep(pollIntervalMS); + } + while (DateTime.UtcNow < deadline); + + return false; + } + + private static bool ModernPackageRegistered() + { + try + { + return new Windows.Management.Deployment.PackageManager() + .FindPackagesForUser(string.Empty) + .Any(package => package.Id.Name.Contains(ModernPackageName, StringComparison.OrdinalIgnoreCase)); + } + catch (Exception) + { + return false; + } + } + + private static bool WaitForElementSearch(Session session, By by, int timeoutMS) => + session.WaitFor( + () => session.Has(by, timeoutMS: 500), + timeoutMS: timeoutMS, + pollIntervalMS: 200); + + private static Session NavigateToFileLocksmithSettings() + { + var settings = Session.FromProcess( + "PowerToys.Settings", + PowerToysModule.PowerToysSettings, + timeoutMS: 15_000); + if (WaitForElementSearch(settings, By.AccessibilityId("FileLocksmithEnableFileLocksmith"), timeoutMS: 5_000)) + { + return settings; + } + + if (!WaitForElementSearch(settings, By.AccessibilityId("FileLocksmithNavItem"), timeoutMS: 5_000)) + { + settings.Find<NavigationViewItem>(By.AccessibilityId("FileManagementNavItem")).Click(msPostAction: 500); + Assert.IsTrue( + WaitForElementSearch(settings, By.AccessibilityId("FileLocksmithNavItem"), timeoutMS: 5_000), + "The File Management navigation group did not expose File Locksmith."); + } + + settings.Find<NavigationViewItem>(By.AccessibilityId("FileLocksmithNavItem")).Click(msPostAction: 500); + Assert.IsTrue( + WaitForElementSearch(settings, By.AccessibilityId("FileLocksmithEnableFileLocksmith"), timeoutMS: 60_000), + "The File Locksmith settings page did not become ready."); + return settings; + } + + private static ToggleSwitch SetModuleEnabled(ToggleSwitch toggle, bool enabled) + { + for (var attempt = 1; attempt <= 2; attempt++) + { + try + { + toggle.Toggle(enabled); + Assert.IsTrue( + toggle.WaitForProperty("ToggleState", enabled ? "On" : "Off", timeoutMS: 5_000), + $"The File Locksmith enable switch did not settle to {(enabled ? "On" : "Off")}."); + return toggle; + } + catch (TimeoutException) when (attempt < 2) + { + var settings = Session.FromProcess( + "PowerToys.Settings", + PowerToysModule.PowerToysSettings, + timeoutMS: 15_000); + toggle = settings.Find<ToggleSwitch>(By.Name(FileLocksmithConstants.ModuleName), timeoutMS: 15_000); + } + } + + return toggle; + } + + private void AssertCommandPresence(string folder, string[] selection, ContextMenuTier tier, bool expected) + { + var probe = ExplorerHelper.ProbeCommand( + ExplorerHelper.OpenFolder(folder), + () => ExplorerHelper.OpenFolder(folder), + selection, + tier, + FileLocksmithConstants.ContextMenuCaption, + expected, + siblingCaption: FileLocksmithConstants.PowerRenameContextMenuCaption, + TestContext); + + var surface = tier == ContextMenuTier.Classic ? "classic" : "default"; + Assert.IsTrue(probe.Last.IsOpen, $"The {surface} Explorer context menu did not become ready."); + Assert.IsTrue( + probe.Last.HasSibling, + $"The {surface} Explorer context menu did not render the sibling PowerRename command, so its " + + $"'{FileLocksmithConstants.ContextMenuCaption}' state cannot be trusted."); + Assert.AreEqual( + expected, + probe.Last.HasCommand, + $"The {surface} Explorer context menu did {(expected ? "not show" : "show")} " + + $"'{FileLocksmithConstants.ContextMenuCaption}'."); + } + + private LockingProcessFixture CreateFixture(string? targetSubFolder = null) + { + var fixture = new LockingProcessFixture(targetSubFolder); + fixtures.Add(fixture); + return fixture; + } +} diff --git a/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithProcessListTests.cs b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithProcessListTests.cs new file mode 100644 index 000000000000..b787666723b0 --- /dev/null +++ b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithProcessListTests.cs @@ -0,0 +1,280 @@ +// 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 Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.FileLocksmith.UITests; + +/// <summary> +/// Coverage of what the File Locksmith window does once it is open: End task, Reload, automatic +/// delisting, recursive drive scans, and the elevation boundary. +/// </summary> +/// <remarks> +/// These tests start <c>PowerToys.FileLocksmithUI.exe</c> through the product's own paths-file IPC +/// instead of Explorer, so a failure points at the window rather than at the shell. +/// <see cref="FileLocksmithContextMenuTests"/> owns the context-menu surface. +/// </remarks> +[TestClass] +[DoNotParallelize] +public sealed class FileLocksmithProcessListTests : UITestBase +{ + private const int DriveScanTimeoutMS = 180_000; + + private static readonly string[] FileLocksmithModule = { FileLocksmithConstants.ModuleName }; + + private readonly List<LockingProcessFixture> fixtures = new(); + + public FileLocksmithProcessListTests() + : base(PowerToysModule.PowerToysSettings, enableModules: FileLocksmithModule) + { + } + + protected override bool ReuseScopeAcrossTests => true; + + protected override IReadOnlyList<string> StaleProcessNames { get; } = new[] + { + "PowerToys", + "PowerToys.Settings", + FileLocksmithConstants.UiProcessName, + }; + + [TestInitialize] + public void PrepareTest() => Assert.IsTrue( + FileLocksmithUi.Close(), + "A stale File Locksmith window could not be closed before the test."); + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); + FileLocksmithUi.Close(); + + foreach (var fixture in fixtures) + { + fixture.Dispose(); + } + + fixtures.Clear(); + } + + /// <summary> + /// Checklist: End task on each listed process terminates it and removes its row, leaving the + /// empty-list state behind. + /// </summary> + [TestMethod("FileLocksmith.List.EndTask")] + [TestCategory("File Locksmith")] + public void EndTaskTerminatesEachProcessAndRemovesItsRow() + { + var fixture = CreateFixture(); + fixture.Start(count: 2); + + var ui = FileLocksmithUi.Launch(fixture.TargetPath); + AssertRowCount(ui, expected: 2, timeoutMS: 30_000, "File Locksmith did not list both locking processes."); + + for (var remaining = 2; remaining > 0; remaining--) + { + var endTaskLabels = FileLocksmithUi.EndTaskLabels(ui); + Assert.AreEqual(remaining, endTaskLabels.Count, "Every listed process must offer its own End task button."); + endTaskLabels[0].Click(); + + Assert.IsTrue( + fixture.WaitForRunningCount(remaining - 1, timeoutMS: 15_000), + "End task did not terminate the process it was pressed for."); + AssertRowCount( + ui, + expected: remaining - 1, + timeoutMS: 15_000, + "The row of the ended process was not removed from the list."); + } + + Assert.IsTrue( + ui.Has(By.Name(FileLocksmithConstants.EmptyListCaption), timeoutMS: 10_000), + "File Locksmith did not fall back to its empty-list state after every process was ended."); + } + + /// <summary> + /// Checklist: a process started after the scan is only picked up once Reload is pressed. + /// </summary> + [TestMethod("FileLocksmith.List.Reload")] + [TestCategory("File Locksmith")] + public void ReloadRediscoversRestartedProcess() + { + var fixture = CreateFixture(); + fixture.Start(); + + var ui = FileLocksmithUi.Launch(fixture.TargetPath); + AssertRowCount(ui, expected: 1, timeoutMS: 30_000, "File Locksmith did not list the locking process."); + + fixture.KillOne(); + AssertRowCount(ui, expected: 0, timeoutMS: 15_000, "The exited process was not delisted."); + + fixture.Start(); + Assert.AreEqual( + 0, + FileLocksmithUi.CountRows(ui, timeoutMS: 2_000), + "File Locksmith listed a newly started process without being asked to rescan."); + + FileLocksmithUi.ClickReload(ui); + AssertRowCount(ui, expected: 1, timeoutMS: 30_000, "Reload did not rediscover the restarted process."); + } + + /// <summary> + /// Checklist: closing a listed process delists it automatically, with no manual refresh. + /// </summary> + [TestMethod("FileLocksmith.List.AutoDelist")] + [TestCategory("File Locksmith")] + public void ExitedProcessIsDelistedWithoutReload() + { + var fixture = CreateFixture(); + fixture.Start(count: 2); + + var ui = FileLocksmithUi.Launch(fixture.TargetPath); + AssertRowCount(ui, expected: 2, timeoutMS: 30_000, "File Locksmith did not list both locking processes."); + + fixture.KillOne(); + Assert.IsTrue(fixture.WaitForRunningCount(1, timeoutMS: 10_000), "The locking process did not exit."); + AssertRowCount( + ui, + expected: 1, + timeoutMS: 5_000, + "File Locksmith did not delist the exited process on its own within 5s."); + } + + /// <summary> + /// Checklist: a drive-wide scan reports the processes locking files on that volume, and scrolling + /// the (large) result list to the bottom and back does not crash File Locksmith. + /// </summary> + /// <remarks> + /// The list virtualizes, so only the realized rows are in the UIA tree; the specific-process + /// assertions live in the file and directory tests, and this one asserts the volume-wide scan + /// produced rows at all and stayed alive while they were scrolled. + /// </remarks> + [TestMethod("FileLocksmith.List.DriveScan")] + [TestCategory("File Locksmith")] + public void DriveScanListsLockingProcessesAndSurvivesScrolling() + { + var fixture = CreateFixture(); + fixture.Start(); + + var ui = FileLocksmithUi.Launch(DriveScanTimeoutMS, elevated: false, fixture.DriveRoot); + AssertListedProcesses(ui, $"Scanning drive '{fixture.DriveRoot}' reported no locking processes at all."); + + ScrollListEndToEnd(ui); + AssertUiAlive("File Locksmith did not survive scrolling the drive-wide process list."); + + if (!FileLocksmithUi.HostIsElevated) + { + TestContext.WriteLine( + "Skipped the elevated repeat of the drive scan: elevating from a non-elevated test host " + + "raises a UAC prompt that cannot be answered non-interactively."); + return; + } + + var elevatedUi = FileLocksmithUi.Launch(DriveScanTimeoutMS, elevated: true, fixture.DriveRoot); + Assert.AreEqual( + FileLocksmithConstants.ElevatedWindowTitle, + FileLocksmithUi.CurrentWindowTitle(), + "The relaunched File Locksmith did not report itself as elevated."); + AssertListedProcesses(elevatedUi, "The elevated drive-wide scan reported no locking processes at all."); + ScrollListEndToEnd(elevatedUi); + AssertUiAlive("Elevated File Locksmith did not survive scrolling the drive-wide process list."); + } + + /// <summary> + /// Checklist: a non-elevated File Locksmith cannot see a higher-integrity process and offers + /// "Restart as administrator"; the elevated window drops that button and does see the process. + /// </summary> + [TestMethod("FileLocksmith.List.Elevation")] + [TestCategory("File Locksmith")] + public void NonElevatedInstanceCannotSeeElevatedProcess() + { + var fixture = CreateFixture(); + fixture.Start(); + + var ui = FileLocksmithUi.Launch(fixture.TargetPath); + Assert.AreEqual( + FileLocksmithConstants.WindowTitle, + FileLocksmithUi.CurrentWindowTitle(), + "File Locksmith did not start as the non-elevated window."); + Assert.IsTrue( + FileLocksmithUi.HasRestartAsAdminButton(ui), + "A non-elevated File Locksmith must offer 'Restart as administrator'."); + AssertRowCount(ui, expected: 1, timeoutMS: 30_000, "File Locksmith did not list the locking process."); + + if (!FileLocksmithUi.HostIsElevated) + { + TestContext.WriteLine( + "Skipped the elevated half: an elevated locking process and the 'Restart as administrator' " + + "relaunch both need an elevated test host (otherwise UAC prompts block the run)."); + return; + } + + fixture.StartElevated(); + FileLocksmithUi.ClickReload(ui); + AssertRowCount( + ui, + expected: 1, + timeoutMS: 30_000, + "A non-elevated File Locksmith listed a higher-integrity process it cannot inspect."); + + var elevatedUi = FileLocksmithUi.LaunchElevated(fixture.TargetPath); + Assert.AreEqual( + FileLocksmithConstants.ElevatedWindowTitle, + FileLocksmithUi.CurrentWindowTitle(), + "The elevated File Locksmith did not use its administrator window title."); + Assert.IsFalse( + FileLocksmithUi.HasRestartAsAdminButton(elevatedUi), + "An elevated File Locksmith must hide 'Restart as administrator'."); + AssertRowCount( + elevatedUi, + expected: 2, + timeoutMS: 30_000, + "An elevated File Locksmith did not see the higher-integrity locking process."); + } + + private static void ScrollListEndToEnd(Session ui) + { + var list = ui.Find<Element>(By.AccessibilityId(FileLocksmithConstants.ProcessListAutomationId), timeoutMS: 15_000); + for (var pass = 0; pass < 2; pass++) + { + list.ScrollToEdge(toBottom: true); + Thread.Sleep(500); + list.ScrollToEdge(toBottom: false); + Thread.Sleep(500); + } + } + + private static void AssertRowCount(Session ui, int expected, int timeoutMS, string message) + { + var settled = expected > 0 + ? FileLocksmithUi.WaitForRowCountWithReload(ui, expected, timeoutMS) + : FileLocksmithUi.WaitForRowCount(ui, expected, timeoutMS); + Assert.IsTrue( + settled, + $"{message} Expected {expected} row(s), found {FileLocksmithUi.CountRows(ui, timeoutMS: 2_000)}."); + if (expected > 0) + { + Assert.IsTrue( + FileLocksmithUi.HasProcessRow(ui, LockingProcessFixture.LockerFileName), + $"The listed rows were not headed by '{LockingProcessFixture.LockerFileName}'."); + } + } + + private static void AssertUiAlive(string message) => Assert.IsTrue( + FileLocksmithUi.WaitForProcess(FileLocksmithConstants.UiProcessName, expected: true, timeoutMS: 2_000), + message); + + private static void AssertListedProcesses(Session ui, string message) => Assert.IsTrue( + FileLocksmithUi.CountRows(ui, timeoutMS: 30_000) > 0, + message); + + private LockingProcessFixture CreateFixture() + { + var fixture = new LockingProcessFixture(); + fixtures.Add(fixture); + return fixture; + } +} diff --git a/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithTestHelper.cs b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithTestHelper.cs new file mode 100644 index 000000000000..9e637eb1cf64 --- /dev/null +++ b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/FileLocksmithTestHelper.cs @@ -0,0 +1,640 @@ +// 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.Runtime.InteropServices; +using System.Text; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.FileLocksmith.UITests; + +/// <summary> +/// Names File Locksmith exposes to the outside world: its module key, its UI process/window, and the +/// caption both shell extensions register into the Explorer context menu. +/// </summary> +internal static class FileLocksmithConstants +{ + public const string ModuleName = "File Locksmith"; + public const string PowerRenameModuleName = "PowerRename"; + public const string UiProcessName = "PowerToys.FileLocksmithUI"; + public const string UiExecutableName = "PowerToys.FileLocksmithUI.exe"; + public const string ContextMenuCaption = "Unlock with File Locksmith"; + + /// <summary>Sibling PowerToys command used to prove the menu itself still renders.</summary> + public const string PowerRenameContextMenuCaption = "PowerRename"; + + public const string WindowTitle = "File Locksmith"; + public const string ElevatedWindowTitle = "Administrator: File Locksmith"; + + public const string ProcessListAutomationId = "ProcessesListView"; + public const string ReloadAutomationId = "ReloadBtn"; + public const string RestartAsAdminAutomationId = "RestartAsAdminBtn"; + public const string EndTaskCaption = "End task"; + public const string EmptyListCaption = "No results"; +} + +/// <summary> +/// A data file plus a set of uniquely named processes each holding an open handle to it. File +/// Locksmith reports one row per holder — the same shape the release checklist gets from the +/// PowerToys installer's two processes, but with a process name no other process can collide with. +/// </summary> +internal sealed class LockingProcessFixture : IDisposable +{ + /// <summary>Copy of <c>powershell.exe</c>: it can be told to hold a handle and keeps a unique name.</summary> + public const string LockerFileName = "PTFileLocksmithLocker.exe"; + + /// <summary>The file handed to File Locksmith.</summary> + public const string TargetFileName = "locked-file.dat"; + + private static readonly string LockerProcessName = Path.GetFileNameWithoutExtension(LockerFileName); + private static readonly string LockerSourcePath = Path.Combine( + Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + + private readonly List<Process> processes = new(); + + /// <param name="targetSubFolder"> + /// Places the locked file in a sub-folder of <see cref="RootFolder"/> so a scan of the root only + /// finds it when the scan really is recursive. + /// </param> + public LockingProcessFixture(string? targetSubFolder = null) + { + // File Locksmith matches paths by their kernel name and never resolves 8.3 aliases, so a + // short path (Path.GetTempPath() returns one whenever the profile name exceeds 8 characters) + // silently matches nothing. Measured: 0/3 detections short vs 3/3 expanded. + RootFolder = Path.Combine( + GetLongPathName(Path.GetTempPath()), + "PowerToys-FileLocksmith-UITests", + Guid.NewGuid().ToString("N")); + + var targetFolder = targetSubFolder is null ? RootFolder : Path.Combine(RootFolder, targetSubFolder); + Directory.CreateDirectory(targetFolder); + + LockerPath = Path.Combine(targetFolder, LockerFileName); + File.Copy(LockerSourcePath, LockerPath, overwrite: true); + + TargetPath = Path.Combine(targetFolder, TargetFileName); + File.WriteAllText(TargetPath, "PowerToys File Locksmith UI test fixture."); + + Assert.IsTrue( + File.Exists(LockerPath) && File.Exists(TargetPath), + $"The locking fixture was not written to disk under '{targetFolder}'."); + } + + /// <summary>Temp tree that owns the fixture; scanning it must find the holders recursively.</summary> + public string RootFolder { get; } + + /// <summary>Full path of the file the started processes hold open.</summary> + public string TargetPath { get; } + + /// <summary>Full path of the uniquely named executable the holders run.</summary> + public string LockerPath { get; } + + /// <summary>Folder that directly contains <see cref="TargetPath"/>.</summary> + public string TargetFolder => Path.GetDirectoryName(TargetPath)!; + + private string HolderErrorLogPath => Path.Combine(RootFolder, "holder-error.log"); + + /// <summary>Volume root the fixture lives on, e.g. <c>C:\</c>.</summary> + public string DriveRoot => Path.GetPathRoot(Path.GetFullPath(RootFolder))!; + + public int RunningCount => processes.Count(process => !HasExited(process)); + + /// <summary> + /// Start <paramref name="count"/> more holders at medium integrity and wait until every one is + /// alive. File Locksmith launched from the context menu always runs non-elevated + /// (<c>RunNonElevatedEx</c>) and cannot inspect a higher-integrity process, so the fixture must + /// stay medium-IL even when the test host is elevated. + /// </summary> + public void Start(int count = 1) + { + // Expect the holders alive now plus the new ones: a test that killed a holder earlier must + // not be held to the total ever started. + var expectedAlive = RunningCount + count; + + for (var index = 0; index < count; index++) + { + processes.Add(FileLocksmithUi.HostIsElevated ? StartViaShell() : StartAsChild()); + } + + Assert.IsTrue( + WaitForRunningCount(expectedAlive, timeoutMS: 20_000), + $"Only {RunningCount} of {expectedAlive} locking processes stayed alive.{HolderDiagnostics()}"); + + // A holder that started is not yet a holder that locked, and one holder locking is not all of + // them: require a ready marker per holder so a fixture shortfall is never reported as a File + // Locksmith failure. + Assert.IsTrue( + WaitForHoldersReady(expectedAlive, timeoutMS: 30_000), + $"Only {ReadyHolderCount} of {expectedAlive} locking processes opened " + + $"'{TargetPath}'.{HolderDiagnostics()}"); + } + + /// <summary> + /// Start one holder that inherits the elevated test host's token. Only prompt-free (and only + /// meaningful) when the host is already elevated. + /// </summary> + public Process StartElevated() + { + Assert.IsTrue(FileLocksmithUi.HostIsElevated, "An elevated locking process needs an elevated test host."); + var expectedAlive = RunningCount + 1; + var process = StartAsChild(); + processes.Add(process); + Assert.IsTrue( + WaitForRunningCount(expectedAlive, timeoutMS: 20_000) && + WaitForHoldersReady(expectedAlive, timeoutMS: 30_000), + $"The elevated locking process did not open '{TargetPath}'.{HolderDiagnostics()}"); + return process; + } + + /// <summary>Terminate the oldest live instance without going through the File Locksmith UI.</summary> + public void KillOne() + { + var process = processes.FirstOrDefault(candidate => !HasExited(candidate)); + Assert.IsNotNull(process, "No locking process was alive to terminate."); + TryKill(process!); + } + + public bool WaitForRunningCount(int expected, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + if (RunningCount == expected) + { + return true; + } + + Thread.Sleep(200); + } + while (DateTime.UtcNow < deadline); + + return RunningCount == expected; + } + + public void Dispose() + { + foreach (var process in processes) + { + TryKill(process); + process.Dispose(); + } + + processes.Clear(); + TryDeleteRoot(); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetLongPathNameW(string lpszShortPath, System.Text.StringBuilder lpszLongPath, uint cchBuffer); + + private static string GetLongPathName(string path) + { + var buffer = new StringBuilder(short.MaxValue); + return GetLongPathNameW(path, buffer, (uint)buffer.Capacity) > 0 ? buffer.ToString() : path; + } + + private static bool HasExited(Process process) + { + try + { + return process.HasExited; + } + catch + { + return true; + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(10_000); + } + } + catch + { + // The fixture may already be gone — that's exactly what several tests assert. + } + } + + /// <summary> + /// FileShare.Read (not None) so every holder really keeps its own handle: an exclusive open would + /// let only the first process hold the file and File Locksmith would correctly report one row. + /// A holder marks itself ready only after the open succeeds, and records why it could not. + /// </summary> + private string BuildHolderCommand() => + "try { $handle = [IO.File]::Open('" + TargetPath + "', 'Open', 'Read', 'Read') } " + + "catch { $_.Exception.ToString() | Set-Content '" + HolderErrorLogPath + "'; exit 1 } " + + "New-Item -ItemType File -Force -Path ('" + RootFolder + "\\ready-' + $PID + '.marker') | Out-Null; " + + "Start-Sleep -Seconds 900"; + + private int ReadyHolderCount => processes.Count(process => + !HasExited(process) && File.Exists(Path.Combine(RootFolder, $"ready-{process.Id}.marker"))); + + private bool WaitForHoldersReady(int expected, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + if (ReadyHolderCount >= expected) + { + return true; + } + + Thread.Sleep(250); + } + while (DateTime.UtcNow < deadline); + + return ReadyHolderCount >= expected; + } + + private string HolderDiagnostics() + { + try + { + if (File.Exists(HolderErrorLogPath)) + { + return $" Holder error: {File.ReadAllText(HolderErrorLogPath).Trim()}"; + } + } + catch + { + // Diagnostics must never mask the assertion being reported. + } + + return string.Empty; + } + + private Process StartAsChild() + { + var process = Process.Start(new ProcessStartInfo + { + FileName = LockerPath, + ArgumentList = { "-NoProfile", "-NonInteractive", "-Command", BuildHolderCommand() }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + + Assert.IsNotNull(process, $"The locking-process fixture '{LockerPath}' could not be started."); + return process!; + } + + /// <summary> + /// Hand the launch to the (medium-integrity) shell so an elevated test host does not pass its own + /// token down. Explorer takes no arguments, so a generated VBScript starts the holder hidden from + /// creation; unlike a temporary .cmd console, it cannot steal foreground from the next Explorer. + /// </summary> + private Process StartViaShell() + { + var knownProcessIds = Process.GetProcessesByName(LockerProcessName) + .Select(process => + { + var id = process.Id; + process.Dispose(); + return id; + }) + .ToHashSet(); + + var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(BuildHolderCommand())); + var escapedLockerPath = LockerPath.Replace("\"", "\"\""); + var launcher = Path.Combine(RootFolder, $"start-{Guid.NewGuid():N}.vbs"); + var launcherCommand = + $"CreateObject(\"WScript.Shell\").Run \"\"\"{escapedLockerPath}\"\" -NoProfile " + + $"-NonInteractive -WindowStyle Hidden -EncodedCommand {encodedCommand}\", 0, False{Environment.NewLine}"; + File.WriteAllText(launcher, launcherCommand); + + using var shellLaunch = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"\"{launcher}\"", + UseShellExecute = true, + }); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + do + { + var started = Process.GetProcessesByName(LockerProcessName) + .FirstOrDefault(process => !knownProcessIds.Contains(process.Id)); + if (started is not null) + { + return started; + } + + Thread.Sleep(200); + } + while (DateTime.UtcNow < deadline); + + Assert.Fail($"The shell did not start the locking-process fixture '{LockerPath}'."); + return null!; + } + + private void TryDeleteRoot() + { + for (var attempt = 0; attempt < 5; attempt++) + { + try + { + if (Directory.Exists(RootFolder)) + { + Directory.Delete(RootFolder, recursive: true); + } + + return; + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + Thread.Sleep(250); + } + } +} + +/// <summary> +/// Drives the File Locksmith window: writes the same paths file the shell extensions write, starts +/// <c>PowerToys.FileLocksmithUI.exe</c>, and reads/acts on the process list. +/// </summary> +/// <remarks> +/// Launching the UI directly reproduces the product's own IPC contract +/// (<c>%LocalAppData%\Microsoft\PowerToys\File Locksmith\last-run.log</c>, UTF-16 paths terminated by +/// a blank line — see <c>FileLocksmithLib/IPC.cpp</c> and +/// <c>FileLocksmithLibInterop/NativeMethods.cpp</c>). It keeps the list-behaviour tests independent +/// of Explorer; <see cref="FileLocksmithContextMenuTests"/> covers the context-menu surface itself. +/// </remarks> +internal static class FileLocksmithUi +{ + private const int LaunchTimeoutMS = 30_000; + + private static readonly Lazy<string> ExecutablePathValue = new(ResolveExecutablePath); + + /// <summary>True when the test host is elevated, which every child process it starts inherits.</summary> + public static bool HostIsElevated { get; } = ElevationHelper.IsCurrentProcessElevated(); + + /// <summary>Resolved path of <c>PowerToys.FileLocksmithUI.exe</c> in the build under test.</summary> + public static string ExecutablePath => ExecutablePathValue.Value; + + public static string PathsFilePath => Path.Combine( + SettingsConfigHelper.PowerToysSettingsRoot, + FileLocksmithConstants.ModuleName, + "last-run.log"); + + /// <summary>Start File Locksmith on <paramref name="paths"/> and wait until its list has loaded.</summary> + public static Session Launch(params string[] paths) => Launch(LaunchTimeoutMS, elevated: false, paths); + + /// <summary>Start an elevated File Locksmith. Only prompt-free when the test host is elevated.</summary> + public static Session LaunchElevated(params string[] paths) => Launch(LaunchTimeoutMS, elevated: true, paths); + + public static Session Launch(int loadTimeoutMS, bool elevated, params string[] paths) + { + Assert.IsTrue(paths.Length > 0, "At least one path must be handed to File Locksmith."); + Close(); + WritePathsFile(paths); + StartProcess(elevated); + return WaitForWindow(loadTimeoutMS); + } + + /// <summary>Bind to the File Locksmith window and block until its process list finished loading.</summary> + public static Session WaitForWindow(int loadTimeoutMS) + { + var window = WindowsFinder.WaitForWindowByApp( + FileLocksmithConstants.UiProcessName, + candidate => candidate.Width > 0 && candidate.Height > 0, + timeoutMS: LaunchTimeoutMS); + Assert.IsNotNull(window, "The File Locksmith window did not open."); + var foregroundReady = WaitHelper.WaitForStable( + observe: WindowControl.GetForegroundWindowInfo, + isMatch: foreground => foreground.ProcessId == window!.ProcessId, + timeoutMS: 10_000, + requiredConsecutiveMatches: 2, + recover: _ => WindowControl.TryFocusByApp( + window!.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture))).Succeeded; + if (!foregroundReady) + { + Console.WriteLine( + $"File Locksmith foreground could not be confirmed; continuing with UIA. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + } + + Assert.IsTrue( + WaitForLoaded(window, loadTimeoutMS), + $"File Locksmith was still scanning after {loadTimeoutMS}ms — its process list never appeared."); + return window; + } + + /// <summary> + /// The list only enters the UIA tree once <c>IsLoading</c> flips back to false, so its presence is + /// the authoritative "scan finished" signal. + /// </summary> + public static bool WaitForLoaded(Session ui, int timeoutMS) => ui.WaitFor( + () => ui.Has(By.AccessibilityId(FileLocksmithConstants.ProcessListAutomationId), timeoutMS: 1_000), + timeoutMS: timeoutMS, + pollIntervalMS: 500); + + /// <summary> + /// The "End task" label of every listed row, top row first. The label is matched, not its Button: + /// the button wraps an icon+text panel and exposes no UIA name of its own, so a Button-typed search + /// finds nothing. Clicking the label lands inside the button. + /// </summary> + public static IReadOnlyList<TextBlock> EndTaskLabels(Session ui, int timeoutMS = 5_000) => + ui.FindAll<TextBlock>(By.Name(FileLocksmithConstants.EndTaskCaption), timeoutMS) + .Where(label => + label.Name.Equals(FileLocksmithConstants.EndTaskCaption, StringComparison.OrdinalIgnoreCase) && + label.Width > 0 && + label.Height > 0) + .OrderBy(label => label.Y) + .ToList(); + + /// <summary> + /// Number of listed rows, counted by their End task labels - one per row, and independent of the + /// process name, which the paths header also displays. + /// </summary> + public static int CountRows(Session ui, int timeoutMS = 5_000) => EndTaskLabels(ui, timeoutMS).Count; + + /// <summary>True when at least one row is headed by <paramref name="processName"/>.</summary> + public static bool HasProcessRow(Session ui, string processName, int timeoutMS = 5_000) => + ui.FindAll<TextBlock>(By.Name(processName), timeoutMS) + .Any(row => row.Name.Equals(processName, StringComparison.OrdinalIgnoreCase)); + + public static bool WaitForRowCount(Session ui, int expected, int timeoutMS) => + ui.WaitFor( + () => CountRows(ui, timeoutMS: expected == 0 ? 500 : 2_000) == expected, + timeoutMS: timeoutMS, + pollIntervalMS: 500); + + /// <summary> + /// Wait for <paramref name="expected"/> rows, re-scanning through Reload between attempts. The + /// window scans once when it opens, so a scan that came up short can only be retried the way a + /// user would - by pressing Reload. + /// </summary> + public static bool WaitForRowCountWithReload(Session ui, int expected, int timeoutMS, int reloadAttempts = 3) + { + var perAttempt = Math.Max(timeoutMS / (reloadAttempts + 1), 3_000); + for (var attempt = 0; ; attempt++) + { + if (WaitForRowCount(ui, expected, perAttempt)) + { + return true; + } + + if (attempt >= reloadAttempts) + { + return false; + } + + ClickReload(ui); + WaitForLoaded(ui, timeoutMS: 30_000); + } + } + + /// <summary>Press the toolbar Reload (refresh) button and let the rescan start.</summary> + public static void ClickReload(Session ui) => + ui.Find<Button>(By.AccessibilityId(FileLocksmithConstants.ReloadAutomationId), timeoutMS: 10_000) + .Click(msPostAction: 300); + + public static bool HasRestartAsAdminButton(Session ui, int timeoutMS = 3_000) => + ui.Has(By.AccessibilityId(FileLocksmithConstants.RestartAsAdminAutomationId), timeoutMS); + + /// <summary>Live window title, re-read from Win32 so an elevated relaunch is observed.</summary> + public static string? CurrentWindowTitle() => + WindowsFinder.ListByApp(FileLocksmithConstants.UiProcessName) + .FirstOrDefault(window => window.Width > 0 && window.Height > 0)? + .Title; + + public static bool Close() + { + if (WindowControl.TryCloseByApp(FileLocksmithConstants.UiProcessName, timeoutMS: 5_000) && + WaitForProcess(FileLocksmithConstants.UiProcessName, expected: false, timeoutMS: 2_000)) + { + return true; + } + + return WindowControl.TryKillProcessTreeByNameAndWait(FileLocksmithConstants.UiProcessName, timeoutMS: 10_000); + } + + public static bool WaitForProcess(string processName, bool expected, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + var processes = Process.GetProcessesByName(processName); + var running = processes.Length > 0; + foreach (var process in processes) + { + process.Dispose(); + } + + if (running == expected) + { + return true; + } + + Thread.Sleep(250); + } + while (DateTime.UtcNow < deadline); + + return false; + } + + /// <summary> + /// Write the UTF-16 paths file exactly as <c>ipc::Writer</c> does: every path followed by a wide + /// newline, then one more newline as the terminator the reader stops on. + /// </summary> + private static void WritePathsFile(IReadOnlyList<string> paths) + { + var builder = new StringBuilder(); + foreach (var path in paths) + { + builder.Append(path).Append('\n'); + } + + builder.Append('\n'); + + Directory.CreateDirectory(Path.GetDirectoryName(PathsFilePath)!); + File.WriteAllBytes(PathsFilePath, Encoding.Unicode.GetBytes(builder.ToString())); + } + + private static void StartProcess(bool elevated) + { + var executable = ExecutablePathValue.Value; + var workingDirectory = Path.GetDirectoryName(executable)!; + + if (elevated) + { + using var elevatedLaunch = Process.Start(new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = workingDirectory, + UseShellExecute = true, + Verb = "runas", + }); + return; + } + + if (!HostIsElevated) + { + using var directLaunch = Process.Start(new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = workingDirectory, + UseShellExecute = true, + }); + return; + } + + // An elevated test host would hand its own token to a direct child, and File Locksmith + // behaves differently when elevated. Hand the launch to the (medium-integrity) shell instead, + // which is what the context-menu extension's RunNonElevatedEx does. + using var shellLaunch = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"\"{executable}\"", + UseShellExecute = true, + }); + } + + private static string ResolveExecutablePath() + { + var candidates = new List<string>(); + + var overrideDirectory = Environment.GetEnvironmentVariable("POWERTOYS_INSTALL_DIR"); + if (!string.IsNullOrEmpty(overrideDirectory)) + { + candidates.Add(Path.Combine(overrideDirectory, "WinUI3Apps", FileLocksmithConstants.UiExecutableName)); + } + + // The build output that holds WinUI3Apps is an ancestor of the test assembly, both locally + // (<root>\<plat>\<cfg>\tests\<proj>\<tfm>\) and in CI (the downloaded build artifact). + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + candidates.Add(Path.Combine(directory.FullName, "WinUI3Apps", FileLocksmithConstants.UiExecutableName)); + } + + candidates.Add(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "PowerToys", + "WinUI3Apps", + FileLocksmithConstants.UiExecutableName)); + candidates.Add(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PowerToys", + "WinUI3Apps", + FileLocksmithConstants.UiExecutableName)); + + var resolved = candidates.FirstOrDefault(File.Exists); + Assert.IsNotNull( + resolved, + $"'{FileLocksmithConstants.UiExecutableName}' was not found. Looked in:{Environment.NewLine}" + + string.Join(Environment.NewLine, candidates.Distinct())); + return resolved!; + } +} diff --git a/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/app.manifest b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/app.manifest new file mode 100644 index 000000000000..d990268c0b52 --- /dev/null +++ b/src/modules/FileLocksmith/Tests/FileLocksmith.UITests/app.manifest @@ -0,0 +1,19 @@ +<?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="FileLocksmith.UITests.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> + <!-- Element.Click drives a real mouse at the physical-pixel bounds winappcli reports, so a + DPI-unaware host would click the wrong place on a scaled display. --> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> +</assembly> diff --git a/src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizer.UITests.csproj b/src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizer.UITests.csproj new file mode 100644 index 000000000000..132fe0717f49 --- /dev/null +++ b/src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizer.UITests.csproj @@ -0,0 +1,33 @@ +<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.ImageResizer.UITests</RootNamespace> + <AssemblyName>ImageResizer.UITests</AssemblyName> + <ApplicationManifest>app.manifest</ApplicationManifest> + + <IsTestingPlatformApplication>true</IsTestingPlatformApplication> + <EnableMSTestRunner>true</EnableMSTestRunner> + <GenerateDocumentationFile>false</GenerateDocumentationFile> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ImageResizer.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizerEndToEndTests.cs b/src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizerEndToEndTests.cs new file mode 100644 index 000000000000..bc924334de22 --- /dev/null +++ b/src/modules/imageresizer/tests/ImageResizer.UITests/ImageResizerEndToEndTests.cs @@ -0,0 +1,1221 @@ +// 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; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.ImageResizer.UITests; + +[TestClass] +[DoNotParallelize] +public sealed class ImageResizerEndToEndTests : UITestBase +{ + private const string ClassicContextMenuClassName = "#32768"; + private const string ContextMenuCaption = "Resize with Image Resizer"; + private const string ExplorerProcessName = "explorer"; + private const string ImageResizerModuleName = "Image Resizer"; + private const string ImageResizerProcessName = "PowerToys.ImageResizer"; + private const string ModernPackageName = "ImageResizerContextMenu"; + private const string ModernContextMenuClassName = "Microsoft.UI.Content.PopupWindowSiteBridge"; + private const int DialogTimeoutMS = 30_000; + private const int ExplorerTimeoutMS = 30_000; + private const int ResizeTimeoutMS = 60_000; + private static readonly string[] ImageResizerModule = { ImageResizerModuleName }; + private static readonly ResizePreset DefaultPreset = new("UITest", ResizeFitMode.Fit, 100, 100, ResizeUnitMode.Pixel); + private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true }; + private static readonly string ImageResizerSettingsPath = Path.Combine( + SettingsConfigHelper.PowerToysSettingsRoot, + ImageResizerModuleName, + "settings.json"); + + private static readonly string ImageResizerSizesPath = Path.Combine( + SettingsConfigHelper.PowerToysSettingsRoot, + ImageResizerModuleName, + "sizes.json"); + + private static bool originalSettingsFileExisted; + private static string? originalSettingsContent; + private static bool originalSizesFileExisted; + private static string? originalSizesContent; + private static bool contextMenuExplorerRefreshed; + + private readonly List<string> temporaryFolders = new(); + private long explorerWindowHandle; + + public ImageResizerEndToEndTests() + : base(PowerToysModule.PowerToysSettings, enableModules: ImageResizerModule) + { + } + + protected override bool ReuseScopeAcrossTests => true; + + [ClassInitialize] + public static void InitializeClass(TestContext testContext) + { + _ = testContext; + originalSettingsFileExisted = File.Exists(ImageResizerSettingsPath); + originalSettingsContent = originalSettingsFileExisted ? File.ReadAllText(ImageResizerSettingsPath) : null; + originalSizesFileExisted = File.Exists(ImageResizerSizesPath); + originalSizesContent = originalSizesFileExisted ? File.ReadAllText(ImageResizerSizesPath) : null; + ConfigureResizeSettings(DefaultPreset); + } + + [ClassCleanup] + public static void CleanupClass() + { + TryRestoreSettingsFile(ImageResizerSizesPath, originalSizesFileExisted, originalSizesContent); + TryRestoreSettingsFile(ImageResizerSettingsPath, originalSettingsFileExisted, originalSettingsContent); + } + + [TestInitialize] + public void PrepareTest() + { + Assert.IsTrue(CloseImageResizerWindows(), "A stale Image Resizer process could not be closed before the test."); + Assert.IsTrue(CloseExplorerFileWindows(), "Stale Explorer file windows could not be closed before the test."); + } + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); + CloseImageResizerWindows(); + CloseExplorerFileWindows(); + explorerWindowHandle = 0; + ConfigureResizeSettings(DefaultPreset); + + foreach (var folder in temporaryFolders) + { + if (!DeleteDirectoryWithRetry(folder)) + { + TestContext.WriteLine($"Cleanup could not delete temporary folder '{folder}'."); + } + } + + temporaryFolders.Clear(); + } + + [TestMethod("ImageResizer.ContextMenu.EnabledState")] + [TestCategory("Image Resizer")] + public void ContextMenuTracksModuleEnabledState() + { + var settings = NavigateToImageResizerSettings(); + var toggle = settings.Find<ToggleSwitch>(By.Name("Image Resizer")); + Assert.IsTrue(toggle.IsOn, "Image Resizer did not start from the deterministic enabled baseline."); + var fixture = CreateImageFixture("context-menu.png", 400, 200); + var folder = Path.GetDirectoryName(fixture)!; + + try + { + toggle = SetModuleEnabled(toggle, false); + var explorer = OpenExplorer(folder); + + // Assert the real per-OS surface (modern tier-1 on Windows 11, classic on Windows 10) + // with no classic fallback on Windows 11 — CI signs the sparse package so it registers. + AssertContextMenuPresence(explorer, new[] { fixture }, expected: false); + + toggle = SetModuleEnabled(toggle, true); + Assert.IsTrue( + WaitForModernPackageRegistration(timeoutMS: 30_000), + "The signed Image Resizer sparse package did not finish registering after the module was re-enabled."); + contextMenuExplorerRefreshed = false; + explorer = OpenExplorer(folder); + AssertContextMenuPresence(explorer, new[] { fixture }, expected: true); + } + finally + { + try + { + SetModuleEnabled(toggle, true); + } + catch (Exception ex) + { + TestContext.WriteLine($"Restoring the Image Resizer toggle failed; restarting the deterministic scope. {ex.Message}"); + RestartScope(ImageResizerModule); + } + } + } + + [TestMethod("ImageResizer.Settings.CustomPreset")] + [TestCategory("Image Resizer")] + public void RemovedAndAddedPresetsPopulateResizeWindow() + { + var removablePreset = new ResizePreset("Remove Me", ResizeFitMode.Fit, 320, 200, ResizeUnitMode.Pixel); + var retainedPreset = new ResizePreset("Keep Me", ResizeFitMode.Fill, 200, 120, ResizeUnitMode.Pixel); + ConfigureResizeSettings(new[] { removablePreset, retainedPreset }); + RestartScope(ImageResizerModule); + var settings = NavigateToImageResizerSettings(); + + var removeButton = FindExact<Button>(settings, "Remove the Remove Me preset"); + Assert.IsNotNull(removeButton, "The removable preset was not shown in Image Resizer settings."); + removeButton!.Click(); + + // The confirmation dialog can swallow the first click before its button is hit-testable, so + // re-press Yes on every poll until the preset is actually gone. + var settingsProcess = Session.FromProcess("PowerToys.Settings"); + Assert.IsTrue( + settings.WaitFor( + () => + { + FindExact<Button>(settingsProcess, "Yes", timeoutMS: 500)?.Click(); + return FindExact<Button>(settings, "Remove the Remove Me preset", timeoutMS: 250) is null; + }, + timeoutMS: 15_000, + pollIntervalMS: 500), + "The preset remained visible after confirming its removal."); + + settings.Find<Button>(By.AccessibilityId("AddSizeButton")).Click(); + var editNewPreset = FindExact<Button>(settings, "Edit the New size 1 preset"); + Assert.IsNotNull(editNewPreset, "Adding a preset did not create 'New size 1'."); + editNewPreset!.Click(msPostAction: 500); + + // The expander toggle can miss its hit-test, leaving the editor collapsed; re-open it (the + // pencil is only present while collapsed) until its Name field is exposed. + settingsProcess = Session.FromProcess("PowerToys.Settings"); + var nameBox = FindExact<TextBox>(settingsProcess, "Name", timeoutMS: 2_000); + for (var attempt = 0; nameBox is null && attempt < 5; attempt++) + { + FindExact<Button>(settings, "Edit the New size 1 preset", timeoutMS: 1_000)?.Click(msPostAction: 750); + nameBox = FindExact<TextBox>(settingsProcess, "Name", timeoutMS: 2_000); + } + + Assert.IsNotNull(nameBox, "The new preset editor did not expose its Name field."); + nameBox!.SetText("UITest Custom"); + KeyboardHelper.SendKeys(Key.Esc); + + Assert.IsTrue( + settings.WaitFor( + () => FindExact<Button>(settings, "Edit the UITest Custom preset", timeoutMS: 250) is not null, + timeoutMS: 5_000, + pollIntervalMS: 250), + "The renamed custom preset was not persisted in Settings."); + + var fixture = CreateImageFixture("preset.png", 400, 200); + var dialog = OpenResizeDialog(fixture); + var dialogProcess = Session.FromProcess(ImageResizerProcessName); + dialogProcess.Find<ComboBox>(By.AccessibilityId("SizeComboBox")).Click(msPostAction: 300); + + Assert.IsNotNull( + FindExact<Element>(dialogProcess, "UITest Custom"), + "The newly added preset was not populated in the Image Resizer window."); + Assert.IsNull( + FindExact<Element>(dialogProcess, "Remove Me", timeoutMS: 500), + "The removed preset was still populated in the Image Resizer window."); + KeyboardHelper.SendKeys(Key.Esc); + Assert.IsTrue(dialog.Has(By.AccessibilityId("SizeComboBox")), "The Image Resizer window closed unexpectedly."); + } + + [TestMethod("ImageResizer.Resize.SingleAndMultiple")] + [TestCategory("Image Resizer")] + public void ResizesSingleAndMultipleImages() + { + ConfigureResizeSettings(DefaultPreset); + + var singleFolder = CreateTestFolder(); + var single = CreateImageFixture(singleFolder, "single.png", 400, 200); + ResizeFiles(single); + var singleOutput = WaitForResizedCopies(new[] { single }, expectedCount: 1).Single(); + AssertImageDimensions(singleOutput, 100, 50); + + var multipleFolder = CreateTestFolder(); + var landscape = CreateImageFixture(multipleFolder, "landscape.png", 400, 200); + var portrait = CreateImageFixture(multipleFolder, "portrait.png", 200, 400); + ResizeFiles(landscape, portrait); + var multipleOutputs = WaitForResizedCopies(new[] { landscape, portrait }, expectedCount: 2); + + AssertImageDimensions( + multipleOutputs.Single(path => Path.GetFileName(path).StartsWith("landscape", StringComparison.OrdinalIgnoreCase)), + 100, + 50); + AssertImageDimensions( + multipleOutputs.Single(path => Path.GetFileName(path).StartsWith("portrait", StringComparison.OrdinalIgnoreCase)), + 50, + 100); + } + + [TestMethod("ImageResizer.Resize.GifWarning")] + [TestCategory("Image Resizer")] + public void GifSelectionShowsAnimationWarning() + { + ConfigureResizeSettings(DefaultPreset); + var gif = CreateImageFixture("animated.gif", 200, 100); + var dialog = OpenResizeDialog(gif); + + const string warning = "Gif files with animations may not be correctly resized."; + Assert.IsNotNull( + FindExact<Element>(dialog, warning), + $"The Image Resizer window did not show the expected GIF warning: '{warning}'."); + } + + [TestMethod("ImageResizer.Resize.FitModes")] + [TestCategory("Image Resizer")] + [DataRow("Fill", 0, 100, 100)] + [DataRow("Fit", 1, 100, 50)] + [DataRow("Stretch", 2, 100, 100)] + public void ResizesImagesWithEveryFitMode(string modeName, int fitValue, int expectedWidth, int expectedHeight) + { + var preset = new ResizePreset(modeName, (ResizeFitMode)fitValue, 100, 100, ResizeUnitMode.Pixel); + ConfigureResizeSettings(preset); + var folder = CreateTestFolder(); + var source = CreateStripedImageFixture(folder, $"{modeName.ToLowerInvariant()}.png", 400, 200); + + ResizeFiles(source); + var output = WaitForResizedCopies(new[] { source }, expectedCount: 1).Single(); + AssertImageDimensions(output, expectedWidth, expectedHeight); + + if ((ResizeFitMode)fitValue == ResizeFitMode.Fill) + { + AssertPixelDominatedBy(output, 10, 50, ColorChannel.Green); + AssertPixelDominatedBy(output, 90, 50, ColorChannel.Green); + } + else if ((ResizeFitMode)fitValue == ResizeFitMode.Stretch) + { + AssertPixelDominatedBy(output, 10, 50, ColorChannel.Red); + AssertPixelDominatedBy(output, 90, 50, ColorChannel.Blue); + } + } + + [TestMethod("ImageResizer.Resize.Units")] + [TestCategory("Image Resizer")] + [DataRow("Centimeters", 0, 2.54, 2.54, 96, 96)] + [DataRow("Inches", 1, 1.0, 1.0, 96, 96)] + [DataRow("Percent", 2, 50.0, 50.0, 200, 100)] + [DataRow("Pixels", 3, 120.0, 80.0, 120, 80)] + public void ResizesImagesUsingEveryDimensionUnit( + string unitName, + int unitValue, + double width, + double height, + int expectedWidth, + int expectedHeight) + { + var preset = new ResizePreset(unitName, ResizeFitMode.Stretch, width, height, (ResizeUnitMode)unitValue); + ConfigureResizeSettings(preset); + var source = CreateImageFixture($"{unitName.ToLowerInvariant()}.png", 400, 200); + + ResizeFiles(source); + var output = WaitForResizedCopies(new[] { source }, expectedCount: 1).Single(); + AssertImageDimensions(output, expectedWidth, expectedHeight); + } + + [TestMethod("ImageResizer.Resize.FilenameFormat")] + [TestCategory("Image Resizer")] + public void AppliesFilenameFormatToResizedImage() + { + const string format = "%1 - %2 - %3 - %4 - %5 - %6"; + var preset = new ResizePreset("Format", ResizeFitMode.Fit, 100, 100, ResizeUnitMode.Pixel); + ConfigureResizeSettings(preset, fileNameFormat: format); + var source = CreateImageFixture("format.png", 400, 200); + + ResizeFiles(source); + var output = WaitForResizedCopies(new[] { source }, expectedCount: 1).Single(); + Assert.AreEqual( + "format - Format - 100 - 100 - 100 - 50.png", + Path.GetFileName(output), + "The resized image filename did not apply all six format parameters."); + } + + [TestMethod("ImageResizer.Resize.KeepDateModified")] + [TestCategory("Image Resizer")] + public void KeepsOriginalModifiedDateWhenReplacingImage() + { + ConfigureResizeSettings(DefaultPreset, replace: true, keepDateModified: true); + var source = CreateImageFixture("keep-date.png", 400, 200); + var originalModifiedTime = new DateTime(2020, 2, 3, 4, 5, 6, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(source, originalModifiedTime); + + var dialog = OpenResizeDialog(source); + AssertDialogCheckBox(dialog, "Overwrite files", expected: true); + ClickResizeAndWait(dialog); + + AssertImageDimensions(source, 100, 50); + Assert.AreEqual( + originalModifiedTime, + File.GetLastWriteTimeUtc(source), + "Replacing the image changed its original modified timestamp."); + Assert.AreEqual(0, GetResizedCopies(new[] { source }).Count, "Replacing the image created an unexpected copy."); + } + + [TestMethod("ImageResizer.Resize.ShrinkOnly")] + [TestCategory("Image Resizer")] + public void ShrinkOnlyDoesNotEnlargeSmallerImage() + { + var largePreset = new ResizePreset("Large Target", ResizeFitMode.Fit, 800, 800, ResizeUnitMode.Pixel); + ConfigureResizeSettings(largePreset, shrinkOnly: true); + var source = CreateImageFixture("smaller.png", 400, 200); + + var dialog = OpenResizeDialog(source); + AssertDialogCheckBox(dialog, "Make pictures smaller but not larger", expected: true); + ClickResizeAndWait(dialog); + + var output = WaitForResizedCopies(new[] { source }, expectedCount: 1).Single(); + AssertImageDimensions(output, 400, 200); + } + + [TestMethod("ImageResizer.Resize.ReplaceOriginal")] + [TestCategory("Image Resizer")] + public void ReplacesOriginalImageWithoutCreatingCopy() + { + ConfigureResizeSettings(DefaultPreset, replace: true); + var source = CreateImageFixture("replace.png", 400, 200); + + var dialog = OpenResizeDialog(source); + AssertDialogCheckBox(dialog, "Overwrite files", expected: true); + ClickResizeAndWait(dialog); + + AssertImageDimensions(source, 100, 50); + Assert.AreEqual(0, GetResizedCopies(new[] { source }).Count, "Replacing the image created an unexpected copy."); + } + + [TestMethod("ImageResizer.Resize.Orientation")] + [TestCategory("Image Resizer")] + public void UncheckedIgnoreOrientationUsesUnswappedDimensions() + { + var portraitTarget = new ResizePreset("Portrait", ResizeFitMode.Stretch, 100, 200, ResizeUnitMode.Pixel); + + ConfigureResizeSettings(portraitTarget, ignoreOrientation: true); + var swappedSource = CreateImageFixture("orientation-ignored.png", 400, 200); + ResizeFiles(swappedSource); + var swappedOutput = WaitForResizedCopies(new[] { swappedSource }, expectedCount: 1).Single(); + AssertImageDimensions(swappedOutput, 200, 100); + + ConfigureResizeSettings(portraitTarget, ignoreOrientation: false); + var unswappedSource = CreateImageFixture("orientation-honored.png", 400, 200); + var dialog = OpenResizeDialog(unswappedSource); + AssertDialogCheckBox(dialog, "Ignore the orientation of pictures", expected: false); + ClickResizeAndWait(dialog); + + var unswappedOutput = WaitForResizedCopies(new[] { unswappedSource }, expectedCount: 1).Single(); + AssertImageDimensions(unswappedOutput, 100, 200); + } + + private static Session NavigateToImageResizerSettings() + { + var settings = Session.FromProcess( + "PowerToys.Settings", + PowerToysModule.PowerToysSettings, + timeoutMS: 15_000); + if (WaitForElementSearch(settings, By.AccessibilityId("AddSizeButton"), timeoutMS: 5_000)) + { + return settings; + } + + if (!WaitForElementSearch(settings, By.AccessibilityId("ImageResizerNavItem"), timeoutMS: 5_000)) + { + settings.Find<NavigationViewItem>(By.AccessibilityId("FileManagementNavItem")).Click(msPostAction: 500); + Assert.IsTrue( + WaitForElementSearch(settings, By.AccessibilityId("ImageResizerNavItem"), timeoutMS: 5_000), + "The File Management navigation group did not expose Image Resizer."); + } + + settings.Find<NavigationViewItem>(By.AccessibilityId("ImageResizerNavItem")).Click(msPostAction: 500); + Assert.IsTrue( + WaitForElementSearch(settings, By.AccessibilityId("AddSizeButton"), timeoutMS: 60_000), + "Image Resizer settings page did not become ready."); + return settings; + } + + private static bool WaitForElementSearch(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); + Assert.IsTrue( + toggle.WaitForProperty("ToggleState", enabled ? "On" : "Off", timeoutMS: 5_000), + $"Image Resizer enable switch did not settle to {(enabled ? "On" : "Off")}."); + return toggle; + } + catch (TimeoutException) when (attempt < 2) + { + var settings = Session.FromProcess( + "PowerToys.Settings", + PowerToysModule.PowerToysSettings, + timeoutMS: 15_000); + toggle = settings.Find<ToggleSwitch>(By.Name("Image Resizer"), timeoutMS: 15_000); + } + } + + return toggle; + } + + private Session OpenResizeDialog(params string[] filePaths) + { + Assert.IsTrue(filePaths.Length > 0, "At least one image must be selected."); + var folderPath = Path.GetDirectoryName(filePaths[0])!; + Assert.IsTrue( + filePaths.All(path => string.Equals(Path.GetDirectoryName(path), folderPath, StringComparison.OrdinalIgnoreCase)), + "All selected images must be in the same folder."); + + var explorer = OpenExplorer(folderPath); + var menuDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(90); + Session? menu = null; + Element? resizeMenuItem = null; + var selectionFailures = 0; + do + { + var selected = TrySelectFilesStable(explorer, filePaths, timeoutMS: 12_000); + if (selected is null) + { + // A view opened during the one-time shell restart can stay empty; reopen it. + if (++selectionFailures >= 2) + { + selectionFailures = 0; + explorer = OpenExplorer(folderPath); + } + + Thread.Sleep(300); + continue; + } + + selectionFailures = 0; + explorer = selected; + menu = OpenContextMenu(explorer); + if (menu is not null) + { + resizeMenuItem = FindVisibleMenuItem(menu, ContextMenuCaption, timeoutMS: 5_000); + if (resizeMenuItem is not null) + { + break; + } + } + + KeyboardHelper.SendKeys(Key.Esc); + Thread.Sleep(300); + } + while (DateTime.UtcNow < menuDeadline); + + Assert.IsNotNull(menu, "Explorer did not open the expected image-file context-menu surface."); + Assert.IsNotNull( + resizeMenuItem, + $"Explorer did not show the '{ContextMenuCaption}' command for the selected image(s)."); + resizeMenuItem!.Invoke(msPostAction: 300); + + var dialog = WindowsFinder.WaitForWindowByApp( + ImageResizerProcessName, + window => window.Width >= 400 && window.Height > 0, + timeoutMS: DialogTimeoutMS); + Assert.IsNotNull(dialog, "The Image Resizer window did not open after invoking its context-menu command."); + Assert.IsTrue( + dialog!.WaitForElement(By.AccessibilityId("SizeComboBox"), timeoutMS: 10_000), + "The Image Resizer input page did not become ready."); + Assert.IsTrue( + WindowControl.WaitForForeground(new IntPtr(dialog.WindowHandle), timeoutMS: 10_000, requiredConsecutiveMatches: 2), + $"The Image Resizer window did not become foreground. Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + return dialog; + } + + private void ResizeFiles(params string[] filePaths) + { + var dialog = OpenResizeDialog(filePaths); + ClickResizeAndWait(dialog); + } + + private static void ClickResizeAndWait(Session dialog) + { + var resizeButton = FindExact<Button>(dialog, "Resize"); + Assert.IsNotNull(resizeButton, "The Image Resizer input page did not expose its Resize button."); + resizeButton!.Click(); + Assert.IsTrue( + WaitForProcess(ImageResizerProcessName, expected: false, timeoutMS: ResizeTimeoutMS), + $"{ImageResizerProcessName} did not exit after completing the resize operation."); + } + + private static void AssertDialogCheckBox(Session dialog, string name, bool expected) + { + var checkBox = FindExact<CheckBox>(dialog, name); + Assert.IsNotNull(checkBox, $"The Image Resizer window did not expose the '{name}' checkbox."); + Assert.AreEqual(expected, checkBox!.IsChecked, $"The '{name}' checkbox had the wrong state."); + } + + private void AssertContextMenuPresence( + Session explorer, + string[] filePaths, + bool expected) + { + var folder = Path.GetDirectoryName(filePaths[0])!; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(90); + var lastObservation = new ContextMenuObservation(false, false); + var selectionFailures = 0; + + do + { + KeyboardHelper.SendKeys(Key.Esc); + + // On a slow agent Explorer can render its file view asynchronously after the module + // toggles (or open an empty view right after the one-time shell restart). Re-establish a + // stable selection before each attempt and reopen a fresh window if it keeps failing. + var selected = TrySelectFilesStable(explorer, filePaths, timeoutMS: 12_000); + if (selected is null) + { + TestContext.WriteLine( + $"Selection not established. folder='{folder}' exists={Directory.Exists(folder)} " + + $"files=[{(Directory.Exists(folder) ? string.Join(", ", Directory.GetFiles(folder).Select(Path.GetFileName)) : "<none>")}] " + + $"fixtureOnDisk={filePaths.All(File.Exists)} temp='{Path.GetTempPath()}'."); + if (++selectionFailures >= 2) + { + selectionFailures = 0; + explorer = OpenExplorer(folder); + } + + Thread.Sleep(300); + continue; + } + + selectionFailures = 0; + explorer = selected; + var menu = OpenContextMenu(explorer); + if (menu is null) + { + Thread.Sleep(300); + continue; + } + + var stableObservation = WaitHelper.WaitForStable( + observe: () => ObserveContextMenu(menu), + isMatch: observation => observation is not null && observation.IsOpen && observation.CommandPresent == expected, + timeoutMS: 8_000, + requiredConsecutiveMatches: 4, + pollIntervalMS: 250); + lastObservation = stableObservation.LastObservation ?? lastObservation; + KeyboardHelper.SendKeys(Key.Esc); + + if (stableObservation.Succeeded) + { + return; + } + + Thread.Sleep(300); + } + while (DateTime.UtcNow < deadline); + + var surface = UseModernContextMenu ? "modern" : "classic"; + Assert.IsTrue( + lastObservation.IsOpen, + $"The {surface} Explorer context menu did not become ready."); + Assert.AreEqual( + expected, + lastObservation.CommandPresent, + $"The {surface} Explorer context menu did {(expected ? "not show" : "show")} '{ContextMenuCaption}'."); + } + + private static Session? OpenContextMenu(Session explorer) + { + EnsureExplorerForeground(explorer); + KeyboardHelper.SendKeys(Key.Esc); + + // Windows 11 shows the modern (tier-1) surface directly; Windows 10 the classic one. The + // Image Resizer command is registered into whichever the OS shows, so no "Show more options" + // step (and no classic fallback on Windows 11) is needed. + var surfaceClass = IsWindows11OrNewer() ? ModernContextMenuClassName : ClassicContextMenuClassName; + if (!WindowControl.TryOpenContextMenuForFocusedControl(new IntPtr(explorer.WindowHandle))) + { + return null; + } + + return WaitForContextMenuSurface(surfaceClass, timeoutMS: 15_000); + } + + private static Session? WaitForContextMenuSurface( + string className, + int timeoutMS) => + WindowsFinder.WaitForWindow( + window => IsContextMenuClass(window.ClassName, className), + timeoutMS: timeoutMS, + pollIntervalMS: 100); + + private static bool IsContextMenuClass(string actualClassName, string expectedClassName) => + expectedClassName == ClassicContextMenuClassName + ? actualClassName.Equals(expectedClassName, StringComparison.OrdinalIgnoreCase) + : actualClassName.Contains(expectedClassName, StringComparison.OrdinalIgnoreCase); + + private static ContextMenuObservation ObserveContextMenu(Session menu) + { + var menuReady = menu.WindowHandle != 0 && + WindowsFinder.ListAll().Any(window => window.Hwnd == menu.WindowHandle); + if (!menuReady) + { + return new ContextMenuObservation(false, false); + } + + try + { + return new ContextMenuObservation(true, HasVisibleMenuItem(menu, ContextMenuCaption)); + } + catch (Exception) + { + // The transient menu popup can vanish mid-query (winappcli reports its HWND as gone); + // treat it as not-yet-stable so the caller reopens it. + return new ContextMenuObservation(false, false); + } + } + + private static bool HasVisibleMenuItem(Session menu, string name) => + FindVisibleMenuItem(menu, name, timeoutMS: 250) is not null; + + private static Element? FindVisibleMenuItem(Session menu, string name, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + do + { + 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.Displayed && + element.Width > 0 && + element.Height > 0); + if (item is not null) + { + return item; + } + + Thread.Sleep(100); + } + while (DateTime.UtcNow < deadline); + + return null; + } + + private Session OpenExplorer(string folderPath) + { + EnsureContextMenuHandlerLoaded(); + CloseExplorerFileWindows(); + var existingHandles = WindowsFinder.ListByApp(ExplorerProcessName) + .Where(IsExplorerFileWindow) + .Select(window => window.Hwnd) + .ToHashSet(); + + using var process = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/n,\"{folderPath}\"", + UseShellExecute = true, + }); + + var explorer = WindowsFinder.WaitForWindowByApp( + ExplorerProcessName, + window => IsExplorerFileWindow(window) && !existingHandles.Contains(window.Hwnd), + timeoutMS: ExplorerTimeoutMS); + Assert.IsNotNull(explorer, $"Explorer did not open '{folderPath}'."); + + explorerWindowHandle = explorer!.WindowHandle; + EnsureExplorerForeground(explorer); + return explorer; + } + + // Both context-menu handlers are registered at runtime when the module is enabled (the classic + // registry-COM handler always, plus the modern sparse-MSIX package on signed builds). An + // Explorer that was already running only surfaces them after the shell restarts, so do it once. + private static void EnsureContextMenuHandlerLoaded() + { + if (contextMenuExplorerRefreshed) + { + return; + } + + contextMenuExplorerRefreshed = true; + Thread.Sleep(3_000); + + var previousProcessIds = Process.GetProcessesByName(ExplorerProcessName) + .Select(process => + { + var id = process.Id; + process.Dispose(); + return id; + }) + .ToHashSet(); + + WindowControl.TryKillProcessByName(ExplorerProcessName); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (DateTime.UtcNow < deadline) + { + var current = Process.GetProcessesByName(ExplorerProcessName); + var hasFreshShell = current.Any(process => !previousProcessIds.Contains(process.Id)); + foreach (var process in current) + { + process.Dispose(); + } + + if (hasFreshShell) + { + break; + } + + Thread.Sleep(500); + } + + Thread.Sleep(2_000); + } + + private static Session SelectFiles(Session explorer, params string[] filePaths) + { + var selection = ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), + filePaths, + filePaths[0], + timeoutMS: ExplorerTimeoutMS, + requiredConsecutiveMatches: 4); + if (!selection.Succeeded) + { + var replacement = FindReplacementExplorer(explorer, Path.GetDirectoryName(filePaths[0])!); + if (replacement is not null) + { + explorer = replacement; + selection = ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), + filePaths, + filePaths[0], + timeoutMS: ExplorerTimeoutMS, + requiredConsecutiveMatches: 4); + } + } + + var observedSelection = selection.LastObservation; + var selectedPaths = observedSelection is null ? "<none>" : string.Join(", ", observedSelection.SelectedPaths); + var normalizedPaths = filePaths + .Select(path => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var exactTerminalSelection = observedSelection is not null && + observedSelection.SelectedPaths.SetEquals(normalizedPaths) && + string.Equals( + observedSelection.FocusedPath, + Path.TrimEndingDirectorySeparator(Path.GetFullPath(filePaths[0])), + StringComparison.OrdinalIgnoreCase) && + WindowControl.GetForegroundWindowHandle() == new IntPtr(explorer.WindowHandle); + + Assert.IsTrue( + selection.Succeeded || exactTerminalSelection, + $"Explorer selection did not settle. Last selected paths: [{selectedPaths}]. " + + $"Last focused path: '{observedSelection?.FocusedPath ?? "<none>"}'. " + + $"Expected Explorer HWND: {explorer.WindowHandle}. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + return explorer; + } + + // Non-throwing selection used by the context-menu retry loop: re-establishes a stable selection + // (handling an Explorer window that was replaced mid-render) and returns the live session, or + // null if it could not settle within the timeout so the caller can retry. + private static Session? TrySelectFilesStable(Session explorer, string[] filePaths, int timeoutMS) + { + if (ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), filePaths, filePaths[0], timeoutMS, requiredConsecutiveMatches: 4).Succeeded) + { + return explorer; + } + + var replacement = FindReplacementExplorer(explorer, Path.GetDirectoryName(filePaths[0])!); + if (replacement is not null && + ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(replacement.WindowHandle), filePaths, filePaths[0], timeoutMS, requiredConsecutiveMatches: 4).Succeeded) + { + return replacement; + } + + return null; + } + + private static Session? FindReplacementExplorer(Session explorer, string folderPath) + { + var folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(folderPath)); + var foregroundWindow = 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 == foregroundWindow) + .FirstOrDefault(); + if (replacement is null) + { + return null; + } + + return WindowsFinder.WaitForWindow( + window => window.Hwnd == replacement.Hwnd, + timeoutMS: 2_000, + pollIntervalMS: 100); + } + + private static void EnsureExplorerForeground(Session explorer) + { + Assert.IsTrue( + WindowControl.WaitForForeground( + new IntPtr(explorer.WindowHandle), + ExplorerTimeoutMS, + requiredConsecutiveMatches: 3), + $"Explorer HWND {explorer.WindowHandle} was not the stable foreground window. Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + } + + private string CreateTestFolder() + { + var folder = Path.Combine(Path.GetTempPath(), "PowerToys-ImageResizer-UITests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + temporaryFolders.Add(folder); + return folder; + } + + private string CreateImageFixture(string fileName, int width, int height) + { + return CreateImageFixture(CreateTestFolder(), fileName, width, height); + } + + private static string CreateImageFixture(string folder, string fileName, int width, int height) + { + var path = Path.Combine(folder, fileName); + using var image = new Bitmap(width, height); + image.SetResolution(96, 96); + using (var graphics = Graphics.FromImage(image)) + { + graphics.Clear(Color.CornflowerBlue); + } + + var imageFormat = Path.GetExtension(fileName).Equals(".gif", StringComparison.OrdinalIgnoreCase) + ? System.Drawing.Imaging.ImageFormat.Gif + : System.Drawing.Imaging.ImageFormat.Png; + image.Save(path, imageFormat); + + // Confirm the fixture actually reached disk before Explorer is asked to show it; retry once + // to absorb a slow or locked temp on constrained agents. + if (!File.Exists(path)) + { + Thread.Sleep(500); + image.Save(path, imageFormat); + } + + Assert.IsTrue(File.Exists(path), $"Image fixture was not written to disk at '{path}' (temp='{Path.GetTempPath()}')."); + return path; + } + + private static string CreateStripedImageFixture(string folder, string fileName, int width, int height) + { + var path = Path.Combine(folder, fileName); + using var image = new Bitmap(width, height); + image.SetResolution(96, 96); + using (var graphics = Graphics.FromImage(image)) + { + graphics.Clear(Color.Green); + using var red = new SolidBrush(Color.Red); + using var blue = new SolidBrush(Color.Blue); + graphics.FillRectangle(red, 0, 0, width / 4, height); + graphics.FillRectangle(blue, width * 3 / 4, 0, width - (width * 3 / 4), height); + } + + image.Save(path, System.Drawing.Imaging.ImageFormat.Png); + return path; + } + + private static void AssertImageDimensions(string path, int expectedWidth, int expectedHeight) + { + using var image = Image.FromFile(path); + Assert.AreEqual(expectedWidth, image.Width, $"Unexpected width for '{path}'."); + Assert.AreEqual(expectedHeight, image.Height, $"Unexpected height for '{path}'."); + } + + private static void AssertPixelDominatedBy(string path, int x, int y, ColorChannel expectedChannel) + { + using var image = new Bitmap(path); + var pixel = image.GetPixel(x, y); + var expected = expectedChannel switch + { + ColorChannel.Red => pixel.R, + ColorChannel.Green => pixel.G, + ColorChannel.Blue => pixel.B, + _ => 0, + }; + var otherMaximum = expectedChannel switch + { + ColorChannel.Red => Math.Max(pixel.G, pixel.B), + ColorChannel.Green => Math.Max(pixel.R, pixel.B), + ColorChannel.Blue => Math.Max(pixel.R, pixel.G), + _ => byte.MaxValue, + }; + + Assert.IsTrue( + expected >= otherMaximum + 40, + $"Pixel ({x}, {y}) in '{path}' was {pixel}, not predominantly {expectedChannel}."); + } + + private static IReadOnlyList<string> WaitForResizedCopies(IReadOnlyCollection<string> sourcePaths, int expectedCount) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + IReadOnlyList<string> copies; + do + { + copies = GetResizedCopies(sourcePaths); + if (copies.Count == expectedCount) + { + return copies; + } + + Thread.Sleep(200); + } + while (DateTime.UtcNow < deadline); + + Assert.AreEqual(expectedCount, copies.Count, "The expected resized image copies were not created."); + return copies; + } + + private static IReadOnlyList<string> GetResizedCopies(IReadOnlyCollection<string> sourcePaths) + { + var sources = sourcePaths.Select(Path.GetFullPath).ToHashSet(StringComparer.OrdinalIgnoreCase); + var folder = Path.GetDirectoryName(sourcePaths.First())!; + return Directory.EnumerateFiles(folder) + .Select(Path.GetFullPath) + .Where(path => !sources.Contains(path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool WaitForProcess(string processName, bool expected, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (DateTime.UtcNow < deadline) + { + var processes = Process.GetProcessesByName(processName); + var running = processes.Length > 0; + foreach (var process in processes) + { + process.Dispose(); + } + + if (running == expected) + { + return true; + } + + Thread.Sleep(250); + } + + return false; + } + + private static T? FindExact<T>(Session session, string name, int timeoutMS = 5_000) + where T : Element, new() + { + return session.FindAll<T>(By.Name(name), timeoutMS) + .FirstOrDefault(element => element.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + private static void ConfigureResizeSettings( + ResizePreset preset, + bool shrinkOnly = false, + bool replace = false, + bool ignoreOrientation = true, + bool keepDateModified = false, + string fileNameFormat = "%1 (%2)") + { + ConfigureResizeSettings( + new[] { preset }, + shrinkOnly, + replace, + ignoreOrientation, + keepDateModified, + fileNameFormat); + } + + private static void ConfigureResizeSettings( + IReadOnlyList<ResizePreset> presets, + bool shrinkOnly = false, + bool replace = false, + bool ignoreOrientation = true, + bool keepDateModified = false, + string fileNameFormat = "%1 (%2)") + { + var sizes = new JsonArray(); + for (var index = 0; index < presets.Count; index++) + { + sizes.Add(CreatePresetNode(presets[index], index)); + } + + var properties = new JsonObject + { + ["imageresizer_selectedSizeIndex"] = WrappedValue(0), + ["imageresizer_shrinkOnly"] = WrappedValue(shrinkOnly), + ["imageresizer_replace"] = WrappedValue(replace), + ["imageresizer_ignoreOrientation"] = WrappedValue(ignoreOrientation), + ["imageresizer_removeMetadata"] = WrappedValue(false), + ["imageresizer_jpegQualityLevel"] = WrappedValue(90), + ["imageresizer_pngInterlaceOption"] = WrappedValue(0), + ["imageresizer_tiffCompressOption"] = WrappedValue(0), + ["imageresizer_fileName"] = WrappedValue(fileNameFormat), + ["imageresizer_sizes"] = new JsonObject { ["value"] = sizes }, + ["imageresizer_keepDateModified"] = WrappedValue(keepDateModified), + ["imageresizer_fallbackEncoder"] = WrappedValue("19e4a5aa-5662-4fc5-a0c0-1758028e1057"), + ["imageresizer_customSize"] = new JsonObject + { + ["value"] = CreatePresetNode( + new ResizePreset("custom", ResizeFitMode.Fit, 1024, 640, ResizeUnitMode.Pixel), + presets.Count), + }, + }; + var settings = new JsonObject + { + ["name"] = ImageResizerModuleName, + ["version"] = "1", + ["properties"] = properties, + }; + + Directory.CreateDirectory(Path.GetDirectoryName(ImageResizerSettingsPath)!); + File.WriteAllText(ImageResizerSettingsPath, settings.ToJsonString(IndentedJson)); + } + + private static JsonObject CreatePresetNode(ResizePreset preset, int id) => new() + { + ["Id"] = id, + ["name"] = preset.Name, + ["fit"] = (int)preset.Fit, + ["width"] = preset.Width, + ["height"] = preset.Height, + ["unit"] = (int)preset.Unit, + }; + + private static JsonObject WrappedValue<T>(T value) => new() + { + ["value"] = JsonValue.Create(value), + }; + + private static void TryRestoreSettingsFile(string path, bool existed, string? content) + { + try + { + if (existed) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content!); + } + else if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Could not restore Image Resizer settings file '{path}'. {ex.Message}"); + } + } + + private static bool IsWindows11OrNewer() => Environment.OSVersion.Version.Build >= 22_000; + + 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; + } + } + + // Windows 11 shows the tier-1 (modern, sparse-MSIX) context menu; Windows 10 shows the classic + // (registry-COM) menu. CI signs the sparse package so the modern menu registers, so the test + // drives the real per-OS surface with no classic fallback on Windows 11. + private static bool UseModernContextMenu => IsWindows11OrNewer(); + + private static bool IsExplorerFileWindow(WindowsFinder.WindowInfo window) => + window.ClassName.Equals("CabinetWClass", StringComparison.OrdinalIgnoreCase); + + private static bool CloseExplorerFileWindows() => + WindowControl.TryCloseByApp(ExplorerProcessName, IsExplorerFileWindow, timeoutMS: 10_000); + + private static bool CloseImageResizerWindows() + { + if (WindowControl.TryCloseByApp(ImageResizerProcessName, timeoutMS: 5_000) && + WaitForProcess(ImageResizerProcessName, expected: false, timeoutMS: 1_000)) + { + return true; + } + + return WindowControl.TryKillProcessTreeByNameAndWait(ImageResizerProcessName, timeoutMS: 10_000); + } + + private static bool DeleteDirectoryWithRetry(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) + { + } + + if (attempt < 4) + { + Thread.Sleep(250); + } + } + + return !Directory.Exists(path); + } + + private sealed record ContextMenuObservation(bool IsOpen, bool CommandPresent); + + private sealed record ResizePreset( + string Name, + ResizeFitMode Fit, + double Width, + double Height, + ResizeUnitMode Unit); + + private enum ResizeFitMode + { + Fill, + Fit, + Stretch, + } + + private enum ResizeUnitMode + { + Centimeter, + Inch, + Percent, + Pixel, + } + + private enum ColorChannel + { + Red, + Green, + Blue, + } +} diff --git a/src/modules/imageresizer/tests/ImageResizer.UITests/app.manifest b/src/modules/imageresizer/tests/ImageResizer.UITests/app.manifest new file mode 100644 index 000000000000..c3b16cd304c4 --- /dev/null +++ b/src/modules/imageresizer/tests/ImageResizer.UITests/app.manifest @@ -0,0 +1,16 @@ +<?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="ImageResizer.UITests.app" /> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <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> \ No newline at end of file diff --git a/src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj b/src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj index e83fc93998dc..eeddc3e3bca2 100644 --- a/src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj +++ b/src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj @@ -13,6 +13,7 @@ <AssemblyName>ImageResizer.Test</AssemblyName> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\$(AssemblyName)\</OutputPath> + <DefaultItemExcludes>$(DefaultItemExcludes);ImageResizer.UITests\**</DefaultItemExcludes> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index e7f6db165278..f8769d8aac0b 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -14,9 +14,23 @@ <Grid> <ProgressRing + x:Name="LoadingIndicator" HorizontalAlignment="Center" VerticalAlignment="Center" - IsActive="{x:Bind MatchPreviewState(Previewer.State, previewers:PreviewState.Loading), Mode=OneWay}" /> + AutomationProperties.AutomationId="LoadingIndicator" + IsActive="{x:Bind MatchPreviewState(Previewer.State, previewers:PreviewState.Loading), Mode=OneWay}" + Visibility="{x:Bind IsLoadingIndicatorVisible(Previewer.State), Mode=OneWay}" /> + + <TextBlock + x:Name="PreviewStateAutomationPeer" + Width="1" + Height="1" + HorizontalAlignment="Left" + VerticalAlignment="Top" + AutomationProperties.AutomationId="PreviewStateAutomationPeer" + IsHitTestVisible="False" + Opacity="0" + Text="{x:Bind GetPreviewStateText(Previewer.State), Mode=OneWay}" /> <controls:ShellPreviewHandlerControl x:Name="ShellPreviewHandlerPreview" diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs index 77364d1cc407..49feea783133 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs @@ -185,6 +185,16 @@ public bool MatchPreviewState(PreviewState? value, PreviewState stateToMatch) return value == stateToMatch; } + public Visibility IsLoadingIndicatorVisible(PreviewState? state) + { + return MatchPreviewState(state, PreviewState.Loading) ? Visibility.Visible : Visibility.Collapsed; + } + + public string GetPreviewStateText(PreviewState? state) + { + return (state ?? PreviewState.Uninitialized).ToString(); + } + public Visibility IsPreviewVisible(IPreviewer? previewer, PreviewState? state) { var isValidPreview = previewer != null && MatchPreviewState(state, PreviewState.Loaded); diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png new file mode 100644 index 000000000000..e5a9a6bc56ed Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png new file mode 100644 index 000000000000..f6a97a8c35ae Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png new file mode 100644 index 000000000000..a59986a075f3 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png new file mode 100644 index 000000000000..4bc50db2323e Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png new file mode 100644 index 000000000000..19f470e40036 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png new file mode 100644 index 000000000000..83f2d6ae33ef Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png new file mode 100644 index 000000000000..9fa92f1ba69e Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png new file mode 100644 index 000000000000..3343f2195d1e Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png new file mode 100644 index 000000000000..0878eb24cbf2 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png new file mode 100644 index 000000000000..edd054d20693 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png new file mode 100644 index 000000000000..c5154374c95f Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png new file mode 100644 index 000000000000..9d6b102cce05 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png new file mode 100644 index 000000000000..977a4036afe7 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png new file mode 100644 index 000000000000..0cd49bf36e20 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png new file mode 100644 index 000000000000..cf8c9723823e Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png new file mode 100644 index 000000000000..9f308b7e26e2 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png new file mode 100644 index 000000000000..e5fff0dcf66b Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png new file mode 100644 index 000000000000..b5892351ef0d Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests.Next/Peek.UITests.Next.csproj b/src/modules/peek/Peek.UITests.Next/Peek.UITests.Next.csproj new file mode 100644 index 000000000000..c3a0e09605e3 --- /dev/null +++ b/src/modules/peek/Peek.UITests.Next/Peek.UITests.Next.csproj @@ -0,0 +1,40 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <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>Peek.UITests</RootNamespace> + <AssemblyName>Peek.UITests.Next</AssemblyName> + <ApplicationManifest>app.manifest</ApplicationManifest> + <IsTestingPlatformApplication>true</IsTestingPlatformApplication> + <EnableMSTestRunner>true</EnableMSTestRunner> + <GenerateDocumentationFile>false</GenerateDocumentationFile> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Peek.UITests.Next\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <!-- ProjectReference does not copy this runtime dependency from UITestAutomation.Next. --> + <PackageReference Include="CoenM.ImageSharp.ImageHash" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" /> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Include="Baseline\*.png" /> + <Content Include="TestAssets\**\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/peek/Peek.UITests.Next/PeekFilePreviewTests.cs b/src/modules/peek/Peek.UITests.Next/PeekFilePreviewTests.cs new file mode 100644 index 000000000000..3bddb06b1f5c --- /dev/null +++ b/src/modules/peek/Peek.UITests.Next/PeekFilePreviewTests.cs @@ -0,0 +1,998 @@ +// 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.Text; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32; + +namespace Peek.UITests; + +[TestClass] +public class PeekFilePreviewTests : UITestBase +{ + private const string PeekProcessName = "PowerToys.Peek.UI"; + private const string PersonalizeRegistryPath = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + private const string AppsUseLightThemeValueName = "AppsUseLightTheme"; + private const int ExplorerOpenTimeoutMS = 30_000; + private const int ExplorerOpenAttempts = 3; + private const int ExplorerSelectionStableSamples = 4; + private const int PeekWindowTimeoutMS = 30_000; + private const int PreviewLoadTimeoutMS = 60_000; + private const int PreviewOpenAttempts = 3; + private const int MaxHotkeyAttempts = 3; + private const int MaxNavigationAttempts = 3; + private static readonly IDisposable PeekSettings; + + private long explorerWindowHandle; + private IReadOnlyList<string> expectedExplorerSelection = Array.Empty<string>(); + private string? expectedExplorerFocusedPath; + + private static bool appsUseLightThemeValueExisted; + private static object? originalAppsUseLightThemeValue; + private static RegistryValueKind originalAppsUseLightThemeValueKind; + private static bool restoreAppsUseLightTheme; + + public PeekFilePreviewTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Small_Vertical, enableModules: new[] { "Peek" }) + { + } + + static PeekFilePreviewTests() + { + PeekSettings = SettingsConfigHelper.PreserveModuleSettings("Peek"); + try + { + ForcePipelineLightTheme(); + + SettingsConfigHelper.UpdateModuleSettings( + "Peek", + """ + { + "name": "Peek", + "version": "1.0", + "properties": {} + } + """, + settings => + { + var properties = settings["properties"] as JsonObject ?? new JsonObject(); + properties["ActivationShortcut"] = new JsonObject + { + ["win"] = false, + ["ctrl"] = true, + ["alt"] = false, + ["shift"] = false, + ["code"] = 32, + ["key"] = "Space", + }; + properties["AlwaysRunNotElevated"] = new JsonObject { ["value"] = true }; + properties["CloseAfterLosingFocus"] = new JsonObject { ["value"] = false }; + properties["ConfirmFileDelete"] = new JsonObject { ["value"] = true }; + properties["EnableSpaceToActivate"] = new JsonObject { ["value"] = false }; + settings["properties"] = properties; + }); + } + catch + { + PeekSettings.Dispose(); + throw; + } + } + + [ClassCleanup] + public static void RestoreClassState() + { + try + { + if (!restoreAppsUseLightTheme) + { + return; + } + + try + { + using var key = Registry.CurrentUser.CreateSubKey(PersonalizeRegistryPath); + if (appsUseLightThemeValueExisted) + { + key.SetValue(AppsUseLightThemeValueName, originalAppsUseLightThemeValue!, originalAppsUseLightThemeValueKind); + } + else + { + key.DeleteValue(AppsUseLightThemeValueName, throwOnMissingValue: false); + } + } + catch + { + } + } + finally + { + PeekSettings.Dispose(); + } + } + + protected override IReadOnlyList<string> StaleProcessNames { get; } = new[] + { + "PowerToys", + "PowerToys.Settings", + "PowerToys.FancyZonesEditor", + PeekProcessName, + }; + + [TestInitialize] + public void PreparePeekTest() + { + CloseTestWindows(); + WindowControl.TryCloseByApp("PowerToys.Settings"); + } + + [TestCleanup] + public async Task CleanupPeekTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(TimeSpan.FromSeconds(2)); + CloseTestWindows(); + } + + [TestMethod("Peek.FilePreview.Folder")] + [TestCategory("Preview files")] + public void PeekFolderFilePreview() + { + var folderPath = Path.GetFullPath(@".\TestAssets"); + var peekWindow = OpenPeekWindow(folderPath); + + peekWindow.Find<TextBlock>(By.Name("File Type: File folder"), 5_000); + } + + [TestMethod("Peek.FilePreview.JPEGImage")] + [TestCategory("Preview files")] + public void PeekJPEGImagePreview() + { + TestSingleFilePreview(Path.GetFullPath(@".\TestAssets\2.jpg"), "2"); + } + + [TestMethod("Peek.FilePreview.QOIImage")] + [TestCategory("Preview files")] + public void PeekQOIImagePreview() + { + TestSingleFilePreview(Path.GetFullPath(@".\TestAssets\4.qoi"), "4"); + } + + [TestMethod("Peek.FilePreview.CPPSourceCode")] + [TestCategory("Preview files")] + public void PeekCPPSourceCodePreview() + { + TestSingleFilePreview(Path.GetFullPath(@".\TestAssets\5.cpp"), "5"); + } + + [TestMethod("Peek.FilePreview.MarkdownDocument")] + [TestCategory("Preview files")] + public void PeekMarkdownDocumentPreview() + { + TestSingleFilePreview(Path.GetFullPath(@".\TestAssets\6.md"), "6"); + } + + [TestMethod("Peek.FilePreview.ZIPArchive")] + [TestCategory("Preview files")] + public void PeekZIPArchivePreview() + { + TestSingleFilePreview(Path.GetFullPath(@".\TestAssets\7.zip"), "7"); + } + + [TestMethod("Peek.FilePreview.PNGImage")] + [TestCategory("Preview files")] + public void PeekPNGImagePreview() + { + TestSingleFilePreview(Path.GetFullPath(@".\TestAssets\8.png"), "8"); + } + + [TestMethod("Peek.WindowPinning.PinAndSwitchImages")] + [TestCategory("Window Pinning")] + public void TestPinWindowAndSwitchImages() + { + var firstImagePath = Path.GetFullPath(@".\TestAssets\8.png"); + var secondImagePath = Path.GetFullPath(@".\TestAssets\2.jpg"); + var initialWindow = OpenPeekWindow(firstImagePath); + var movedBounds = MoveWindowBy(initialWindow, 100, 50); + + ClickPinButton(initialWindow); + CloseTestWindows(); + + var secondWindow = OpenPeekWindow(secondImagePath); + AssertBoundsEqual(movedBounds, GetWindowBounds(secondWindow), "when switching images while pinned"); + } + + [TestMethod("Peek.WindowPinning.PinAndReopen")] + [TestCategory("Window Pinning")] + public void TestPinWindowAndReopen() + { + var imagePath = Path.GetFullPath(@".\TestAssets\8.png"); + var initialWindow = OpenPeekWindow(imagePath); + var movedBounds = MoveWindowBy(initialWindow, 150, 75); + + ClickPinButton(initialWindow); + CloseTestWindows(); + + var reopenedWindow = OpenPeekWindow(imagePath); + AssertBoundsEqual(movedBounds, GetWindowBounds(reopenedWindow), "after reopening while pinned"); + } + + [TestMethod("Peek.WindowPinning.UnpinAndSwitchFiles")] + [TestCategory("Window Pinning")] + public void TestUnpinWindowAndSwitchFiles() + { + var firstFilePath = Path.GetFullPath(@".\TestAssets\8.png"); + var secondFilePath = Path.GetFullPath(@".\TestAssets\2.jpg"); + var pinnedWindow = OpenPeekWindow(firstFilePath); + var movedBounds = MoveWindowBy(pinnedWindow, 200, 100); + var movedCenter = movedBounds.Center; + + ClickPinButton(pinnedWindow); + ClickPinButton(pinnedWindow); + CloseTestWindows(preservePeekProcess: false); + + var unpinnedWindow = OpenPeekWindow(secondFilePath); + var unpinnedBounds = GetWindowBounds(unpinnedWindow); + var unpinnedCenter = unpinnedBounds.Center; + + var sizeChanged = Math.Abs(movedBounds.Width - unpinnedBounds.Width) > 10 || + Math.Abs(movedBounds.Height - unpinnedBounds.Height) > 10; + var centerChanged = Math.Abs(movedCenter.X - unpinnedCenter.X) > 50 || + Math.Abs(movedCenter.Y - unpinnedCenter.Y) > 50; + + Assert.IsTrue(sizeChanged, "Window size should be different for different file types."); + Assert.IsTrue(centerChanged, "Window center should move to its default position when unpinned."); + } + + [TestMethod("Peek.WindowPinning.UnpinAndReopen")] + [TestCategory("Window Pinning")] + public void TestUnpinWindowAndReopen() + { + var imagePath = Path.GetFullPath(@".\TestAssets\8.png"); + var initialWindow = OpenPeekWindow(imagePath); + var movedBounds = MoveWindowBy(initialWindow, 250, 125); + + ClickPinButton(initialWindow); + ClickPinButton(initialWindow); + CloseTestWindows(preservePeekProcess: false); + + var reopenedWindow = OpenPeekWindow(imagePath); + var reopenedBounds = GetWindowBounds(reopenedWindow); + var openedAtDefault = Math.Abs(movedBounds.Left - reopenedBounds.Left) > 50 || + Math.Abs(movedBounds.Top - reopenedBounds.Top) > 50; + + Assert.IsTrue(openedAtDefault, "Unpinned window should open at its default position."); + } + + [TestMethod("Peek.OpenWithDefaultProgram.ClickButton")] + [TestCategory("Open with default program")] + public void TestOpenWithDefaultProgramByButton() + { + var zipPath = Path.GetFullPath(@".\TestAssets\7.zip"); + var peekWindow = OpenPeekWindow(zipPath); + + peekWindow.Find<Button>(By.AccessibilityId("LaunchAppButton"), 5_000).Invoke(); + + Assert.IsTrue( + WaitForExplorerTitle(Path.GetFileNameWithoutExtension(zipPath), 10_000), + "The default program did not open the ZIP archive after clicking the launch button."); + } + + [TestMethod("Peek.OpenWithDefaultProgram.PressEnter")] + [TestCategory("Open with default program")] + public void TestOpenWithDefaultProgramByEnter() + { + var zipPath = Path.GetFullPath(@".\TestAssets\7.zip"); + var peekWindow = OpenPeekWindow(zipPath); + + peekWindow.Find<Button>(By.AccessibilityId("LaunchAppButton"), 5_000).Focus(); + KeyboardHelper.SendKeys(Key.Enter); + + Assert.IsTrue( + WaitForExplorerTitle(Path.GetFileNameWithoutExtension(zipPath), 10_000), + "The default program did not open the ZIP archive after pressing Enter."); + } + + [TestMethod("Peek.FileNavigation.SwitchFilesWithArrowKeys")] + [TestCategory("File Navigation")] + public void TestSwitchFilesWithArrowKeys() + { + var testFiles = GetTestAssetFiles(); + var peekWindow = OpenPeekWindow(testFiles[0]); + + for (var index = 1; index < testFiles.Count; index++) + { + peekWindow = NavigateToFileWithRetry(peekWindow, Key.Right, testFiles[index]); + } + + for (var index = testFiles.Count - 2; index >= 0; index--) + { + peekWindow = NavigateToFileWithRetry(peekWindow, Key.Left, testFiles[index]); + } + } + + [TestMethod("Peek.FileNavigation.SwitchBetweenSelectedFiles")] + [TestCategory("File Navigation")] + public void TestSwitchBetweenSelectedFiles() + { + var selectedFiles = GetTestAssetFiles().Take(3).ToList(); + var explorerWindow = OpenExplorerAndSelect(selectedFiles[0]); + SetExplorerSelection(explorerWindow, selectedFiles, selectedFiles[^1]); + + var peekWindow = SendPeekHotkeyWithRetry(selectedFiles[2]); + EnsurePeekWindowInteractive(peekWindow); + var expectedNames = selectedFiles.Select(Path.GetFileNameWithoutExtension).ToHashSet(StringComparer.OrdinalIgnoreCase); + var visitedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + FileNameFromTitle(peekWindow.WindowTitle, expectedNames), + }; + + const int maxAttempts = 10; + for (var attempt = 1; attempt <= maxAttempts && visitedNames.Count < expectedNames.Count; attempt++) + { + var previousTitle = peekWindow.WindowTitle; + EnsurePeekWindowInteractive(peekWindow); + EnsurePeekWindowForeground(peekWindow); + KeyboardHelper.SendKeys(Key.Left); + var changedWindow = WaitForSelectedFileChange(previousTitle, expectedNames, timeoutMS: 2_000); + if (changedWindow is null) + { + TestContext.WriteLine( + $"Selected-file navigation attempt {attempt}/{maxAttempts} made no progress from '{previousTitle}'. Retrying."); + continue; + } + + peekWindow = changedWindow; + visitedNames.Add(FileNameFromTitle(peekWindow.WindowTitle, expectedNames)); + } + + CollectionAssert.AreEquivalent( + expectedNames.ToList(), + visitedNames.ToList(), + $"Peek should visit every selected file and no unselected files. Visited: {string.Join(", ", visitedNames)}."); + } + + private Session OpenPeekWindow(string filePath) + { + OpenExplorerAndSelect(filePath); + + for (var attempt = 1; attempt <= PreviewOpenAttempts; attempt++) + { + try + { + var peekWindow = SendPeekHotkeyWithRetry(filePath); + EnsurePeekReady(peekWindow); + return peekWindow; + } + catch (AssertFailedException ex) when (attempt < PreviewOpenAttempts) + { + TestContext.WriteLine( + $"Peek activation/readiness attempt {attempt}/{PreviewOpenAttempts} failed for " + + $"'{Path.GetFileName(filePath)}': {ex.Message}. Restarting Peek before retrying activation."); + WindowControl.TryCloseByApp(PeekProcessName, timeoutMS: 10_000); + Assert.IsTrue(StopPeekProcess(), "Peek did not stop before the next activation attempt."); + } + } + + Assert.Fail($"Peek did not become ready for '{Path.GetFileName(filePath)}'."); + return null!; + } + + private Session OpenExplorerAndSelect(string filePath) + { + Assert.IsTrue(File.Exists(filePath) || Directory.Exists(filePath), $"Test asset does not exist: {filePath}"); + + var normalizedPath = filePath.TrimEnd(Path.DirectorySeparatorChar); + var selectedItemName = Path.GetFileName(normalizedPath); + TestContext.WriteLine(GetActivationDiagnostics($"Opening Explorer for '{normalizedPath}'")); + + for (var attempt = 1; attempt <= ExplorerOpenAttempts; attempt++) + { + CloseExplorerFileWindows(); + + var existingHandles = WindowsFinder.ListByApp("explorer") + .Where(IsExplorerFileWindow) + .Select(window => window.Hwnd) + .ToHashSet(); + + using var launchProcess = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/n,/select,\"{normalizedPath}\"", + UseShellExecute = true, + }); + TestContext.WriteLine( + $"Explorer launch attempt {attempt}/{ExplorerOpenAttempts}: " + + $"launcherPid={launchProcess?.Id.ToString() ?? "unknown"}, existingHwnds=[{string.Join(", ", existingHandles)}]."); + + var explorerWindow = WindowsFinder.WaitForWindowByApp( + "explorer", + window => IsExplorerFileWindow(window) && !existingHandles.Contains(window.Hwnd), + ExplorerOpenTimeoutMS); + + if (explorerWindow is null) + { + TestContext.WriteLine(GetActivationDiagnostics($"No fresh Explorer HWND after launch attempt {attempt}")); + continue; + } + + var expectedExplorerTitle = Path.GetFileName(Path.GetDirectoryName(normalizedPath)); + if (!WaitForExplorerWindowTitle(explorerWindow.WindowHandle, expectedExplorerTitle, ExplorerOpenTimeoutMS)) + { + TestContext.WriteLine( + GetActivationDiagnostics( + $"Explorer HWND {explorerWindow.WindowHandle} did not navigate to '{expectedExplorerTitle}' after launch attempt {attempt}")); + continue; + } + + if (!SetAndWaitForExplorerSelection( + explorerWindow.WindowHandle, + [normalizedPath], + normalizedPath, + ExplorerOpenTimeoutMS)) + { + TestContext.WriteLine( + GetActivationDiagnostics( + $"Explorer HWND {explorerWindow.WindowHandle} did not become foreground with a stable '{selectedItemName}' selection after launch attempt {attempt}")); + continue; + } + + explorerWindowHandle = explorerWindow.WindowHandle; + expectedExplorerSelection = [normalizedPath]; + expectedExplorerFocusedPath = normalizedPath; + TestContext.WriteLine( + $"Explorer ready for '{selectedItemName}': hwnd={explorerWindow.WindowHandle}, " + + $"pid={explorerWindow.ProcessId}, title='{explorerWindow.WindowTitle}', " + + $"session={GetProcessSessionId(explorerWindow.ProcessId)}, elevated={FormatElevation(explorerWindow.IsElevated)}."); + return explorerWindow; + } + + Assert.Fail( + $"Explorer did not open for {selectedItemName} after {ExplorerOpenAttempts} launch attempts." + + Environment.NewLine + GetActivationDiagnostics("Explorer launch failed")); + return null!; + } + + private static bool WaitForExplorerWindowTitle(long windowHandle, string expectedTitle, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + + while (DateTime.UtcNow < deadline) + { + var window = WindowControl.EnumerateAllWindows() + .FirstOrDefault(candidate => candidate.Hwnd.ToInt64() == windowHandle); + if (window.Hwnd != IntPtr.Zero && + window.Title.Contains(expectedTitle, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + Thread.Sleep(250); + } + + return false; + } + + private void SetExplorerSelection( + Session explorerWindow, + IReadOnlyList<string> selectedPaths, + string focusedPath) + { + var normalizedPaths = selectedPaths.Select(NormalizePath).ToList(); + var normalizedFocusedPath = NormalizePath(focusedPath); + + Assert.IsTrue( + SetAndWaitForExplorerSelection( + explorerWindow.WindowHandle, + normalizedPaths, + normalizedFocusedPath, + ExplorerOpenTimeoutMS), + $"Explorer did not establish the expected selection [{string.Join(", ", normalizedPaths.Select(Path.GetFileName))}] " + + $"with '{Path.GetFileName(normalizedFocusedPath)}' focused."); + + explorerWindowHandle = explorerWindow.WindowHandle; + expectedExplorerSelection = normalizedPaths; + expectedExplorerFocusedPath = normalizedFocusedPath; + } + + private static bool SetAndWaitForExplorerSelection( + long windowHandle, + IReadOnlyList<string> selectedPaths, + string focusedPath, + int timeoutMS) + { + return ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(windowHandle), + selectedPaths, + focusedPath, + timeoutMS, + ExplorerSelectionStableSamples).Succeeded; + } + + private static string NormalizePath(string path) + { + return Path.GetFullPath(path.TrimEnd(Path.DirectorySeparatorChar)); + } + + private Session SendPeekHotkeyWithRetry(string expectedPath) + { + for (var attempt = 1; attempt <= MaxHotkeyAttempts; attempt++) + { + var visiblePeekWindow = WindowsFinder.ListByApp(PeekProcessName).FirstOrDefault(); + if (visiblePeekWindow is not null) + { + return WaitForInitializedPeekWindow( + expectedPath, + visiblePeekWindow.Hwnd, + visiblePeekWindow.ProcessId, + visiblePeekWindow.Title, + ElevationHelper.IsProcessElevated(visiblePeekWindow.ProcessId), + attempt); + } + + Assert.IsFalse( + expectedExplorerSelection.Count == 0 || string.IsNullOrEmpty(expectedExplorerFocusedPath), + "Explorer selection expectations were not initialized before Peek activation."); + var explorerReady = explorerWindowHandle != 0 && + SetAndWaitForExplorerSelection( + explorerWindowHandle, + expectedExplorerSelection, + expectedExplorerFocusedPath!, + ExplorerOpenTimeoutMS); + Assert.IsTrue( + explorerReady, + $"Explorer did not become foreground with the expected stable selection before Peek hotkey attempt {attempt}."); + + TestContext.WriteLine( + $"Peek hotkey attempt {attempt}/{MaxHotkeyAttempts} for '{Path.GetFileName(expectedPath)}': " + + $"explorerHwnd={explorerWindowHandle}, explorerReady={explorerReady}, " + + $"foregroundHwnd={WindowControl.GetForegroundWindowHandle().ToInt64()}."); + KeyboardHelper.SendKeys(Key.Ctrl, Key.Space); + var appearedWindow = WindowsFinder.WaitForWindowByApp( + PeekProcessName, + _ => true, + PeekWindowTimeoutMS); + if (appearedWindow is not null) + { + return WaitForInitializedPeekWindow( + expectedPath, + appearedWindow.WindowHandle, + appearedWindow.ProcessId, + appearedWindow.WindowTitle, + appearedWindow.IsElevated, + attempt); + } + + TestContext.WriteLine(GetActivationDiagnostics($"No matching Peek window after hotkey attempt {attempt}")); + } + + Assert.Fail( + $"Peek did not open {Path.GetFileName(expectedPath)} after {MaxHotkeyAttempts} hotkey attempts." + + Environment.NewLine + GetActivationDiagnostics("Peek activation failed")); + return null!; + } + + private Session WaitForInitializedPeekWindow( + string expectedPath, + long windowHandle, + int processId, + string initialTitle, + bool? isElevated, + int hotkeyAttempt) + { + TestContext.WriteLine( + $"Peek window appeared after hotkey attempt {hotkeyAttempt}: hwnd={windowHandle}, " + + $"pid={processId}, initialTitle='{initialTitle}', " + + $"session={GetProcessSessionId(processId)}, elevated={FormatElevation(isElevated)}. " + + $"Waiting for expected title without resending the hotkey."); + + EnsurePeekWindowForeground(windowHandle); + var initializedWindow = WaitForPeekWindow(expectedPath, PeekWindowTimeoutMS); + if (initializedWindow is not null) + { + EnsurePeekWindowForeground(initializedWindow); + TestContext.WriteLine( + $"Peek initialized after hotkey attempt {hotkeyAttempt}: hwnd={initializedWindow.WindowHandle}, " + + $"pid={initializedWindow.ProcessId}, title='{initializedWindow.WindowTitle}', " + + $"session={GetProcessSessionId(initializedWindow.ProcessId)}, elevated={FormatElevation(initializedWindow.IsElevated)}."); + return initializedWindow; + } + + Assert.Fail( + $"Peek window appeared after hotkey attempt {hotkeyAttempt}, but did not initialize for " + + $"{Path.GetFileName(expectedPath)} within {PeekWindowTimeoutMS / 1_000}s." + + Environment.NewLine + GetActivationDiagnostics("Peek window initialization failed")); + return null!; + } + + private string GetActivationDiagnostics(string stage) + { + var output = new StringBuilder(); + using var testHost = Process.GetCurrentProcess(); + var foreground = WindowControl.GetForegroundWindowInfo(); + output.AppendLine($"[{DateTime.UtcNow:O}] {stage}"); + output.AppendLine( + $"Test host: pid={testHost.Id}, session={GetProcessSessionId(testHost.Id)}, " + + $"elevated={ElevationHelper.IsCurrentProcessElevated()}."); + output.AppendLine( + $"Foreground: hwnd={foreground.Hwnd.ToInt64()}, pid={foreground.ProcessId}, " + + $"process='{foreground.ProcessName}', class='{foreground.ClassName}', title='{foreground.Title}', " + + $"elevated={FormatElevation(foreground.IsElevated)}."); + output.AppendLine( + $"Settings session: pid={Session.ProcessId}, session={GetProcessSessionId(Session.ProcessId)}, " + + $"elevated={FormatElevation(Session.IsElevated)}."); + output.AppendLine( + $"Configured Peek shortcut: Ctrl+Space; AlwaysRunNotElevated=true; EnableSpaceToActivate=false; " + + $"AppsUseLightTheme={ReadAppsUseLightTheme()?.ToString() ?? "unknown"}."); + AppendProcessDiagnostics(output, "PowerToys"); + AppendProcessDiagnostics(output, "explorer"); + AppendProcessDiagnostics(output, PeekProcessName); + AppendWindowDiagnostics(output, "explorer"); + AppendWindowDiagnostics(output, PeekProcessName); + return output.ToString(); + } + + private static void AppendProcessDiagnostics(StringBuilder output, string processName) + { + var processes = Process.GetProcessesByName(processName); + if (processes.Length == 0) + { + output.AppendLine($"Process '{processName}': none."); + return; + } + + foreach (var process in processes) + { + using (process) + { + output.AppendLine( + $"Process '{processName}': pid={process.Id}, session={GetProcessSessionId(process.Id)}, " + + $"elevated={FormatElevation(ElevationHelper.IsProcessElevated(process.Id))}, " + + $"mainHwnd={GetMainWindowHandle(process)}."); + } + } + } + + private static void AppendWindowDiagnostics(StringBuilder output, string appName) + { + var windows = WindowsFinder.ListByApp(appName); + if (windows.Count == 0) + { + output.AppendLine($"Windows for '{appName}': none."); + return; + } + + foreach (var window in windows) + { + output.AppendLine( + $"Window for '{appName}': hwnd={window.Hwnd}, pid={window.ProcessId}, " + + $"class='{window.ClassName}', title='{window.Title}', size={window.Width}x{window.Height}."); + } + } + + private static int? GetProcessSessionId(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return process.SessionId; + } + catch + { + return null; + } + } + + private static long? GetMainWindowHandle(Process process) + { + try + { + return process.MainWindowHandle.ToInt64(); + } + catch + { + return null; + } + } + + private static string FormatElevation(bool? elevated) => elevated?.ToString() ?? "unknown"; + + private static void ForcePipelineLightTheme() + { + if (!EnvironmentConfig.IsInPipeline) + { + return; + } + + try + { + using var key = Registry.CurrentUser.CreateSubKey(PersonalizeRegistryPath); + appsUseLightThemeValueExisted = key.GetValueNames() + .Contains(AppsUseLightThemeValueName, StringComparer.OrdinalIgnoreCase); + if (appsUseLightThemeValueExisted) + { + originalAppsUseLightThemeValue = key.GetValue(AppsUseLightThemeValueName); + originalAppsUseLightThemeValueKind = key.GetValueKind(AppsUseLightThemeValueName); + } + + key.SetValue(AppsUseLightThemeValueName, 1, RegistryValueKind.DWord); + restoreAppsUseLightTheme = true; + } + catch + { + } + } + + private static int? ReadAppsUseLightTheme() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(PersonalizeRegistryPath); + return key?.GetValue(AppsUseLightThemeValueName) is int value ? value : null; + } + catch + { + return null; + } + } + + private static Session? WaitForPeekWindow(string filePath, int timeoutMS) + { + return WindowsFinder.WaitForWindowByApp( + PeekProcessName, + window => WindowTitleMatches(window.Title, filePath), + timeoutMS); + } + + private static Session? WaitForSelectedFileChange(string previousTitle, HashSet<string> expectedNames, int timeoutMS) + { + return WindowsFinder.WaitForWindowByApp( + PeekProcessName, + window => !string.Equals(window.Title, previousTitle, StringComparison.OrdinalIgnoreCase) && + expectedNames.Any(name => TitleMatchesName(window.Title, name)), + timeoutMS); + } + + private static bool WindowTitleMatches(string title, string filePath) + { + return TitleMatchesName(title, Path.GetFileName(filePath)) || + TitleMatchesName(title, Path.GetFileNameWithoutExtension(filePath)); + } + + private static bool TitleMatchesName(string title, string name) + { + return string.Equals(title, name, StringComparison.OrdinalIgnoreCase) || + title.StartsWith(name + ".", StringComparison.OrdinalIgnoreCase) || + title.StartsWith(name + " -", StringComparison.OrdinalIgnoreCase); + } + + private Session NavigateToFileWithRetry(Session peekWindow, Key key, string expectedPath) + { + for (var attempt = 1; attempt <= MaxNavigationAttempts; attempt++) + { + var expectedWindow = WaitForPeekWindow(expectedPath, timeoutMS: 500); + if (expectedWindow is not null) + { + return expectedWindow; + } + + EnsurePeekWindowInteractive(peekWindow); + EnsurePeekWindowForeground(peekWindow); + KeyboardHelper.SendKeys(key); + + expectedWindow = WaitForPeekWindow(expectedPath, PeekWindowTimeoutMS); + if (expectedWindow is not null) + { + return expectedWindow; + } + } + + Assert.Fail( + $"Peek did not navigate to {Path.GetFileName(expectedPath)} after " + + $"{MaxNavigationAttempts} {key} key attempts."); + return null!; + } + + private void EnsurePeekWindowForeground(Session peekWindow) + { + EnsurePeekWindowForeground(peekWindow.WindowHandle); + } + + private void EnsurePeekWindowForeground(long windowHandle) + { + var nativeWindowHandle = new IntPtr(windowHandle); + Assert.IsTrue( + WindowControl.WaitForForeground(nativeWindowHandle, PeekWindowTimeoutMS, pollIntervalMS: 250), + $"Peek HWND {windowHandle} did not become the foreground window within " + + $"{PeekWindowTimeoutMS / 1_000}s." + Environment.NewLine + + GetActivationDiagnostics("Peek foreground activation failed")); + } + + private static void EnsurePeekReady(Session peekWindow) + { + EnsurePeekWindowInteractive(peekWindow); + + var previewState = peekWindow.Find<Element>(By.AccessibilityId("PreviewStateAutomationPeer"), 15_000); + Assert.IsTrue( + previewState.WaitForValue("Loaded", timeoutMS: PreviewLoadTimeoutMS), + $"Peek did not finish loading '{peekWindow.WindowTitle}' within {PreviewLoadTimeoutMS / 1_000}s. " + + $"Last preview state: '{previewState.GetValue()}'."); + + var loadingIndicator = peekWindow + .FindAll<Element>(By.AccessibilityId("LoadingIndicator"), 1_000) + .FirstOrDefault(); + + if (loadingIndicator is not null) + { + Assert.IsTrue( + loadingIndicator.WaitForGone(PreviewLoadTimeoutMS), + $"Peek's loading indicator did not disappear for '{peekWindow.WindowTitle}'."); + } + } + + private static void EnsurePeekWindowInteractive(Session peekWindow) + { + peekWindow.Find<Button>(By.AccessibilityId("PinButton"), 15_000); + } + + private static string FileNameFromTitle(string title, HashSet<string> expectedNames) + { + var match = expectedNames.FirstOrDefault(name => TitleMatchesName(title, name)); + Assert.IsFalse(string.IsNullOrEmpty(match), $"Unexpected Peek window title '{title}'."); + return match!; + } + + private static bool WaitForExplorerTitle(string title, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (DateTime.UtcNow < deadline) + { + if (WindowsFinder.ListByApp("explorer").Any(window => TitleMatchesName(window.Title, title))) + { + return true; + } + + Thread.Sleep(250); + } + + return false; + } + + private void TestSingleFilePreview(string filePath, string expectedFileName) + { + var previewWindow = OpenPeekWindow(filePath); + VisualAssert.AreEqual(TestContext, previewWindow, expectedFileName); + } + + private static WindowBounds MoveWindowBy(Session window, int offsetX, int offsetY) + { + var originalBounds = GetWindowBounds(window); + WindowHelper.MoveWindow( + new IntPtr(window.WindowHandle), + originalBounds.Left + offsetX, + originalBounds.Top + offsetY); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + WindowBounds movedBounds; + do + { + movedBounds = GetWindowBounds(window); + if (Math.Abs(movedBounds.Left - (originalBounds.Left + offsetX)) <= 5 && + Math.Abs(movedBounds.Top - (originalBounds.Top + offsetY)) <= 5) + { + return movedBounds; + } + + Thread.Sleep(100); + } + while (DateTime.UtcNow < deadline); + + Assert.Fail("Peek window did not move to the requested position."); + return default; + } + + private static WindowBounds GetWindowBounds(Session window) + { + var (left, top, right, bottom) = WindowHelper.GetWindowBounds(new IntPtr(window.WindowHandle)); + Assert.IsTrue(right > left && bottom > top, "Peek window has invalid bounds."); + return new WindowBounds(left, top, right - left, bottom - top); + } + + private static void AssertBoundsEqual(WindowBounds expected, WindowBounds actual, string scenario) + { + Assert.AreEqual(expected.Left, actual.Left, 5, $"Window X position should remain the same {scenario}."); + Assert.AreEqual(expected.Top, actual.Top, 5, $"Window Y position should remain the same {scenario}."); + Assert.AreEqual(expected.Width, actual.Width, 10, $"Window width should remain the same {scenario}."); + Assert.AreEqual(expected.Height, actual.Height, 10, $"Window height should remain the same {scenario}."); + } + + private static void ClickPinButton(Session peekWindow) + { + peekWindow.Find<Button>(By.AccessibilityId("PinButton"), 5_000).Click(msPostAction: 500); + } + + private static List<string> GetTestAssetFiles() + { + var testAssetsPath = Path.GetFullPath(@".\TestAssets"); + return Directory.GetFiles(testAssetsPath, "*.*", SearchOption.TopDirectoryOnly) + .Where(file => !Path.GetFileName(file).StartsWith('.')) + .OrderBy(file => file, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private void CloseTestWindows(bool preservePeekProcess = true) + { + var peekClosed = WindowControl.TryCloseByApp(PeekProcessName, timeoutMS: 10_000); + var peekSettled = preservePeekProcess ? WaitForPeekProcessInputIdle() : StopPeekProcess(); + var explorerClosed = CloseExplorerFileWindows(); + + if (!peekClosed || !peekSettled) + { + TestContext.WriteLine( + preservePeekProcess + ? "Cleanup could not close Peek and wait for its UI thread to become idle within 10 seconds." + : "Cleanup could not close and stop Peek within 10 seconds."); + } + + if (!explorerClosed) + { + TestContext.WriteLine("Cleanup could not close every File Explorer window within 10 seconds."); + } + + explorerWindowHandle = 0; + expectedExplorerSelection = Array.Empty<string>(); + expectedExplorerFocusedPath = null; + } + + private static bool WaitForPeekProcessInputIdle() + { + var processes = Process.GetProcessesByName(PeekProcessName); + try + { + foreach (var process in processes) + { + if (!process.WaitForInputIdle(10_000)) + { + return false; + } + } + + return true; + } + catch + { + return false; + } + finally + { + foreach (var process in processes) + { + process.Dispose(); + } + } + } + + private static bool StopPeekProcess() + { + return WindowControl.TryKillProcessTreeByNameAndWait(PeekProcessName); + } + + private static bool CloseExplorerFileWindows() + { + return WindowControl.TryCloseByApp("explorer", IsExplorerFileWindow, timeoutMS: 10_000); + } + + private static bool IsExplorerFileWindow(WindowsFinder.WindowInfo window) + { + return string.Equals(window.ClassName, "CabinetWClass", StringComparison.OrdinalIgnoreCase); + } + + private readonly record struct WindowBounds(int Left, int Top, int Width, int Height) + { + public (int X, int Y) Center => (Left + (Width / 2), Top + (Height / 2)); + } + +} diff --git a/src/modules/peek/Peek.UITests.Next/TestAssets/2.jpg b/src/modules/peek/Peek.UITests.Next/TestAssets/2.jpg new file mode 100644 index 000000000000..808462ae772d Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/TestAssets/2.jpg differ diff --git a/src/modules/peek/Peek.UITests.Next/TestAssets/4.qoi b/src/modules/peek/Peek.UITests.Next/TestAssets/4.qoi new file mode 100644 index 000000000000..90eef44febf9 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/TestAssets/4.qoi differ diff --git a/src/modules/peek/Peek.UITests.Next/TestAssets/5.cpp b/src/modules/peek/Peek.UITests.Next/TestAssets/5.cpp new file mode 100644 index 000000000000..54e47ecd1ebc --- /dev/null +++ b/src/modules/peek/Peek.UITests.Next/TestAssets/5.cpp @@ -0,0 +1,6 @@ +#include <iostream> + +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} diff --git a/src/modules/peek/Peek.UITests.Next/TestAssets/6.md b/src/modules/peek/Peek.UITests.Next/TestAssets/6.md new file mode 100644 index 000000000000..339bae7a486e --- /dev/null +++ b/src/modules/peek/Peek.UITests.Next/TestAssets/6.md @@ -0,0 +1,11 @@ +## 简单的 C++ 示例 + +这是一个最基础的 C++ 程序,它会输出 "Hello, world!": + +```cpp +#include <iostream> + +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} \ No newline at end of file diff --git a/src/modules/peek/Peek.UITests.Next/TestAssets/7.zip b/src/modules/peek/Peek.UITests.Next/TestAssets/7.zip new file mode 100644 index 000000000000..769fc1825338 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/TestAssets/7.zip differ diff --git a/src/modules/peek/Peek.UITests.Next/TestAssets/8.png b/src/modules/peek/Peek.UITests.Next/TestAssets/8.png new file mode 100644 index 000000000000..b6b87e89c4f2 Binary files /dev/null and b/src/modules/peek/Peek.UITests.Next/TestAssets/8.png differ diff --git a/src/modules/peek/Peek.UITests.Next/app.manifest b/src/modules/peek/Peek.UITests.Next/app.manifest new file mode 100644 index 000000000000..3631bbfad0fc --- /dev/null +++ b/src/modules/peek/Peek.UITests.Next/app.manifest @@ -0,0 +1,16 @@ +<?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="Peek.UITests.Next.app"/> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <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> \ No newline at end of file diff --git a/src/modules/previewpane/PreviewPane.UITests/FileExplorerAddonsTests.cs b/src/modules/previewpane/PreviewPane.UITests/FileExplorerAddonsTests.cs new file mode 100644 index 000000000000..e5a024aaa7ad --- /dev/null +++ b/src/modules/previewpane/PreviewPane.UITests/FileExplorerAddonsTests.cs @@ -0,0 +1,1193 @@ +// 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.Runtime.InteropServices; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32; + +namespace Microsoft.PowerToys.PreviewPane.UITests; + +[TestClass] +[DoNotParallelize] +public class FileExplorerAddonsTests : UITestBase +{ + private const string PreviewHandlerShellExtension = "{8895b1c6-b41f-4c1c-a562-0d564250836f}"; + private const string ThumbnailHandlerShellExtension = "{e357fccd-a995-4576-b01f-234630154e96}"; + private const string ThumbnailIsolationRegistryPath = @"Software\Classes\CLSID\{E357FCCD-A995-4576-B01F-234630154E96}"; + private const string DisableProcessIsolationValueName = "DisableProcessIsolation"; + private const string EmptyPreviewPaneText = "Select a file to preview."; + + private const string MarkdownPreviewHandler = "{60789D87-9C3C-44AF-B18C-3DE2C2820ED3}"; + private const string SvgPreviewHandler = "{FCDD4EED-41AA-492F-8A84-31A1546226E0}"; + private const string PdfPreviewHandler = "{A5A41CC7-02CB-41D4-8C9B-9087040D6098}"; + private const string GcodePreviewHandler = "{A0257634-8812-4CE8-AF11-FA69ACAEAFAE}"; + private const string MonacoPreviewHandler = "{D8034CFA-F34B-41FE-AD45-62FCBB52A6DA}"; + + private const string SvgThumbnailProvider = "{10144713-1526-46C9-88DA-1FB52807A9FF}"; + private const string PdfThumbnailProvider = "{D8BB9942-93BD-412D-87E4-33FAB214DC1A}"; + private const string GcodeThumbnailProvider = "{F2847CBE-CD03-4C83-A359-1A8052C1B9D5}"; + private const string StlThumbnailProvider = "{77257004-6F25-4521-B602-50ECC6EC62A6}"; + + private const int ExplorerTimeoutMS = 30_000; + private const int ExplorerOpenAttempts = 3; + private const int PreviewPaneDetectionTimeoutMS = 2_000; + private const int PreviewPaneOpenTimeoutMS = 10_000; + private const int PreviewTimeoutMS = 60_000; + private const int VisualStableTimeoutMS = 15_000; + private const int ExtraLargeIconSize = 256; + private const int LargeIconSize = 96; + private const int MediumIconSize = 48; + private const double PreviewRegionDifferenceThreshold = 0.75; + private static readonly TimeSpan FailureRecordingTail = TimeSpan.FromSeconds(2); + + private static readonly string[] FileExplorerModule = { "File Explorer" }; + private static readonly (string Extension, string Clsid)[] ThumbnailProviders = + { + (".svg", SvgThumbnailProvider), + (".pdf", PdfThumbnailProvider), + (".gcode", GcodeThumbnailProvider), + (".stl", StlThumbnailProvider), + }; + + private static readonly object ExplorerPreparationLock = new(); + private static readonly IDisposable FileExplorerSettings; + private static List<SandboxThumbnailRegistration>? sandboxThumbnailRegistrations; + private static bool explorerPrepared; + + private readonly List<string> temporaryFolders = new(); + private long explorerWindowHandle; + + public FileExplorerAddonsTests() + : base(PowerToysModule.PowerToysSettings, enableModules: FileExplorerModule) + { + } + + protected override bool ReuseScopeAcrossTests => true; + + static FileExplorerAddonsTests() + { + FileExplorerSettings = SettingsConfigHelper.PreserveModuleSettings("File Explorer"); + try + { + SettingsConfigHelper.UpdateModuleSettings( + "File Explorer", + """ + { + "name": "File Explorer", + "version": "1.0", + "properties": {} + } + """, + settings => + { + var properties = settings["properties"] as JsonObject ?? new JsonObject(); + foreach (var settingName in new[] + { + "md-previewer-toggle-setting", + "svg-previewer-toggle-setting", + "pdf-previewer-toggle-setting", + "gcode-previewer-toggle-setting", + "monaco-previewer-toggle-setting", + "svg-thumbnail-toggle-setting", + "pdf-thumbnail-toggle-setting", + "gcode-thumbnail-toggle-setting", + "stl-thumbnail-toggle-setting", + }) + { + properties[settingName] = new JsonObject { ["value"] = true }; + } + + settings["properties"] = properties; + }); + } + catch + { + FileExplorerSettings.Dispose(); + throw; + } + } + + [ClassInitialize] + public static void InitializeClass(TestContext testContext) + { + _ = testContext; + using var process = Process.GetCurrentProcess(); + process.ProcessorAffinity = new IntPtr(1); + Assert.AreEqual(new IntPtr(1), process.ProcessorAffinity, "PreviewPane.UITests must run on logical processor 0."); + } + + [ClassCleanup] + public static void CleanupClass() + { + try + { + if (sandboxThumbnailRegistrations is null) + { + return; + } + + for (var index = sandboxThumbnailRegistrations.Count - 1; index >= 0; index--) + { + sandboxThumbnailRegistrations[index].Dispose(); + } + + sandboxThumbnailRegistrations = null; + } + finally + { + FileExplorerSettings.Dispose(); + } + } + + [TestInitialize] + public void PrepareTest() + { + CloseExplorerFileWindows(); + } + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(FailureRecordingTail); + + CloseExplorerFileWindows(); + explorerWindowHandle = 0; + + foreach (var folder in temporaryFolders) + { + DeleteDirectoryWithRetry(folder); + } + + temporaryFolders.Clear(); + } + + [TestMethod("FileExplorerAddons.Preview.Markdown")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Preview Pane")] + public void MarkdownPreviewShowsReadmeContent() + { + TestPreview( + ".md", + MarkdownPreviewHandler, + "README.md", + "markdown", + "MarkdownPreviewHandler", + "MDPrevHandler"); + } + + [TestMethod("FileExplorerAddons.Preview.SVG")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Preview Pane")] + public void SvgPreviewShowsImageContent() + { + TestPreview(".svg", SvgPreviewHandler, "sample.svg", "svg", "SvgPreviewHandler", "SvgPrevHandler"); + } + + [TestMethod("FileExplorerAddons.Preview.PDF")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Preview Pane")] + public void PdfPreviewShowsDocumentContent() + { + TestPreview(".pdf", PdfPreviewHandler, "sample.pdf", "pdf", "PdfPreviewHandler", "PdfPrevHandler"); + } + + [TestMethod("FileExplorerAddons.Preview.Gcode")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Preview Pane")] + public void GcodePreviewShowsToolpathContent() + { + TestPreview( + ".gcode", + GcodePreviewHandler, + "sample.gcode", + "gcode", + "GcodePreviewHandler", + "GcodePreviewHandler"); + } + + [TestMethod("FileExplorerAddons.Preview.SourceCode")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Preview Pane")] + public void MonacoPreviewShowsSyntaxHighlightedSource() + { + TestPreview( + ".cpp", + MonacoPreviewHandler, + "main.cpp", + "source-code", + "MonacoPreviewHandler", + "MonacoPrevHandler"); + } + + [TestMethod("FileExplorerAddons.Thumbnail.SVG")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Icon Preview")] + public void SvgThumbnailRendersAtMultipleIconSizes() + { + TestThumbnail( + ".svg", + SvgThumbnailProvider, + "sample.svg", + "PowerToys.SvgThumbnailProvider", + "svg"); + } + + [TestMethod("FileExplorerAddons.Thumbnail.PDF")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Icon Preview")] + public void PdfThumbnailRendersAtMultipleIconSizes() + { + TestThumbnail( + ".pdf", + PdfThumbnailProvider, + "sample.pdf", + "PowerToys.PdfThumbnailProvider", + "pdf"); + } + + [TestMethod("FileExplorerAddons.Thumbnail.Gcode")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Icon Preview")] + public void GcodeThumbnailRendersAtMultipleIconSizes() + { + TestThumbnail( + ".gcode", + GcodeThumbnailProvider, + "sample.gcode", + "PowerToys.GcodeThumbnailProvider", + "gcode"); + } + + [TestMethod("FileExplorerAddons.Thumbnail.STL")] + [TestCategory("File Explorer Add-ons")] + [TestCategory("Icon Preview")] + public void StlThumbnailRendersAtMultipleIconSizes() + { + TestThumbnail( + ".stl", + StlThumbnailProvider, + "sample.stl", + "PowerToys.StlThumbnailProvider", + "stl"); + } + + private void TestPreview( + string extension, + string expectedClsid, + string assetName, + string scenario, + string handlerName, + string handlerLogFolder) + { + AssertShellExtensionRegistration(extension, PreviewHandlerShellExtension, expectedClsid, "preview handler"); + PrepareExplorerForRegisteredHandlers(); + + var filePath = TestAssetPath(assetName); + var explorer = OpenExplorer(Path.GetDirectoryName(filePath)!); + EnsurePreviewPaneOpen(explorer); + var handlerLogDirectory = LocalLowHandlerLogDirectory(handlerLogFolder); + DeleteDirectoryWithRetry(handlerLogDirectory); + Assert.IsFalse( + Directory.Exists(handlerLogDirectory), + $"Could not clear the previous {handlerName} log before previewing {extension}."); + + var emptyPreviewPath = CaptureStableWindow(explorer, $"{scenario}-empty"); + SelectFile(explorer, filePath); + + var handlerLog = WaitForProviderLog( + handlerLogDirectory, + $"Starting {handlerName}.exe", + ExplorerTimeoutMS); + Assert.IsNotNull( + handlerLog, + $"Explorer rendered {extension}, but did not invoke the PowerToys {handlerName} shim."); + var handlerLogText = ReadAllTextWithRetry(handlerLog!); + Assert.IsFalse( + handlerLogText.Contains("Failed to start", StringComparison.OrdinalIgnoreCase), + $"The PowerToys {handlerName} shim reported a launch failure.{Environment.NewLine}{handlerLogText}"); + var persistedLogPath = ArtifactPath($"{scenario}-handler", ".log"); + File.WriteAllText(persistedLogPath, handlerLogText); + var renderedPreviewPath = WaitForVisibleChange(explorer, emptyPreviewPath, $"{scenario}-rendered"); + + TestContext.AddResultFile(emptyPreviewPath); + TestContext.AddResultFile(renderedPreviewPath); + TestContext.AddResultFile(persistedLogPath); + } + + private void TestThumbnail( + string extension, + string expectedClsid, + string assetName, + string providerProcessName, + string scenario) + { + AssertShellExtensionRegistration(extension, ThumbnailHandlerShellExtension, expectedClsid, "thumbnail provider"); + PrepareExplorerForRegisteredHandlers(); + + var sourcePath = TestAssetPath(assetName); + var testFolder = CreateTemporaryFolder(); + var destinationPath = Path.Combine(testFolder, assetName); + var explorer = OpenExplorer(testFolder); + var providerName = providerProcessName["PowerToys.".Length..]; + var providerLogDirectory = LocalLowHandlerLogDirectory(providerName); + DeleteDirectoryWithRetry(providerLogDirectory); + Assert.IsFalse( + Directory.Exists(providerLogDirectory), + $"Could not clear the previous {providerName} log before cold thumbnail generation."); + + File.Copy(sourcePath, destinationPath); + KeyboardHelper.SendKeys(Key.F5); + SelectFile(explorer, destinationPath); + SetExplorerViewAndWait( + explorer, + assetName, + ExtraLargeIconSize, + "extra-large icons", + minimumItemHeight: 180, + maximumItemHeight: int.MaxValue); + + var providerLog = WaitForProviderLog( + providerLogDirectory, + $"Start {providerName}.exe", + ExplorerTimeoutMS); + Assert.IsNotNull( + providerLog, + $"Windows Shell did not invoke the PowerToys {providerName} shim for the cold {extension} thumbnail."); + var providerLogText = ReadAllTextWithRetry(providerLog!); + Assert.IsFalse( + providerLogText.Contains("Bmp file not generated", StringComparison.OrdinalIgnoreCase) || + providerLogText.Contains("Failed to start", StringComparison.OrdinalIgnoreCase), + $"The PowerToys {providerName} shim reported a generation failure.{Environment.NewLine}{providerLogText}"); + var persistedLogPath = ArtifactPath($"{scenario}-provider", ".log"); + File.WriteAllText(persistedLogPath, providerLogText); + TestContext.AddResultFile(persistedLogPath); + + var extraLarge = CaptureStableFileItem(explorer, assetName, $"{scenario}-extra-large"); + + SetExplorerViewAndWait( + explorer, + assetName, + LargeIconSize, + "large icons", + minimumItemHeight: Math.Max(80, extraLarge.Height / 4), + maximumItemHeight: extraLarge.Height * 7 / 10); + var large = CaptureStableFileItem(explorer, assetName, $"{scenario}-large"); + SetExplorerViewAndWait( + explorer, + assetName, + MediumIconSize, + "medium icons", + minimumItemHeight: large.Height / 2, + maximumItemHeight: large.Height * 9 / 10); + var medium = CaptureStableFileItem(explorer, assetName, $"{scenario}-medium"); + + AssertThumbnailSizes(extraLarge, large, medium, extension); + + foreach (var capture in new[] { extraLarge, large, medium }) + { + AssertImageHasVisualDetail(capture.Path, extension); + TestContext.AddResultFile(capture.Path); + } + } + + private static void AssertShellExtensionRegistration( + string extension, + string shellExtension, + string expectedClsid, + string handlerDescription) + { + var registryPath = $@"{extension}\shellex\{shellExtension}"; + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + string? actualClsid = null; + + while (DateTime.UtcNow < deadline) + { + using var key = Registry.ClassesRoot.OpenSubKey(registryPath); + actualClsid = key?.GetValue(null) as string; + if (string.Equals(actualClsid, expectedClsid, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + Thread.Sleep(250); + } + + Assert.Fail( + $"PowerToys did not register the effective {extension} {handlerDescription}. " + + $"Expected '{expectedClsid}' at HKCR\\{registryPath}; actual '{actualClsid ?? "<missing>"}'."); + } + + private Session OpenExplorer(string folderPath) + { + for (var attempt = 1; attempt <= ExplorerOpenAttempts; attempt++) + { + CloseExplorerFileWindows(); + var existingHandles = WindowsFinder.ListByApp("explorer") + .Where(IsExplorerFileWindow) + .Select(window => window.Hwnd) + .ToHashSet(); + + using var launchProcess = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/n,\"{folderPath}\"", + UseShellExecute = true, + }); + + var explorer = WindowsFinder.WaitForWindowByApp( + "explorer", + window => IsExplorerFileWindow(window) && !existingHandles.Contains(window.Hwnd), + ExplorerTimeoutMS); + if (explorer is null) + { + TestContext.WriteLine( + $"Explorer attempt {attempt}/{ExplorerOpenAttempts} did not create a fresh HWND for '{folderPath}'."); + continue; + } + + explorerWindowHandle = explorer.WindowHandle; + if (WindowControl.WaitForForeground( + new IntPtr(explorerWindowHandle), + ExplorerTimeoutMS, + requiredConsecutiveMatches: 3)) + { + return explorer; + } + + TestContext.WriteLine( + $"Explorer attempt {attempt}/{ExplorerOpenAttempts} HWND {explorerWindowHandle} did not become foreground. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + } + + Assert.Fail( + $"Explorer did not open a stable foreground window for '{folderPath}' after {ExplorerOpenAttempts} attempts. " + + $"Current foreground: {WindowControl.GetForegroundWindowInfo()}."); + return null!; + } + + private static bool IsExplorerFileWindow(WindowsFinder.WindowInfo window) + { + return window.ClassName.Equals("CabinetWClass", StringComparison.OrdinalIgnoreCase); + } + + private static bool CloseExplorerFileWindows() + { + return WindowControl.TryCloseByApp("explorer", IsExplorerFileWindow, timeoutMS: 10_000); + } + + private static void RestartExplorerShell() + { + var oldProcesses = Process.GetProcessesByName("explorer"); + foreach (var process in oldProcesses) + { + try + { + process.Kill(); + process.WaitForExit(10_000); + } + catch + { + } + finally + { + process.Dispose(); + } + } + + var shell = WindowsFinder.WaitForWindowByApp( + "explorer", + window => window.ClassName.Equals("Shell_TrayWnd", StringComparison.OrdinalIgnoreCase), + timeoutMS: 5_000); + if (shell is null) + { + using var launchProcess = Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + UseShellExecute = true, + }); + shell = WindowsFinder.WaitForWindowByApp( + "explorer", + window => window.ClassName.Equals("Shell_TrayWnd", StringComparison.OrdinalIgnoreCase), + timeoutMS: ExplorerTimeoutMS); + } + + Assert.IsNotNull(shell, "Explorer shell did not restart after File Explorer Add-ons registration."); + } + + private static void PrepareExplorerForRegisteredHandlers() + { + lock (ExplorerPreparationLock) + { + if (explorerPrepared) + { + return; + } + + if (Environment.UserName.Equals("WDAGUtilityAccount", StringComparison.OrdinalIgnoreCase)) + { + sandboxThumbnailRegistrations = new List<SandboxThumbnailRegistration>(); + try + { + foreach (var (extension, clsid) in ThumbnailProviders) + { + AssertShellExtensionRegistration( + extension, + ThumbnailHandlerShellExtension, + clsid, + "thumbnail provider"); + sandboxThumbnailRegistrations.Add(SandboxThumbnailRegistration.Create(extension, clsid)); + } + } + catch + { + for (var index = sandboxThumbnailRegistrations.Count - 1; index >= 0; index--) + { + sandboxThumbnailRegistrations[index].Dispose(); + } + + sandboxThumbnailRegistrations = null; + throw; + } + } + + RestartExplorerShell(); + explorerPrepared = true; + } + } + + private void EnsurePreviewPaneOpen(Session explorer) + { + EnsureExplorerForeground(explorer); + if (WaitForPreviewPane(explorer, PreviewPaneDetectionTimeoutMS)) + { + TestContext.WriteLine("Explorer's Preview pane was already open."); + return; + } + + for (var attempt = 1; attempt <= 2; attempt++) + { + EnsureExplorerForeground(explorer); + KeyboardHelper.SendKeys(Key.Alt, Key.P); + if (WaitForPreviewPane(explorer, PreviewPaneOpenTimeoutMS)) + { + TestContext.WriteLine($"Opened Explorer's Preview pane with Alt+P on attempt {attempt}."); + return; + } + + TestContext.WriteLine($"Explorer's Preview pane was not visible after Alt+P attempt {attempt}."); + } + + Assert.Fail("Explorer's Preview pane did not open after two Alt+P attempts."); + } + + private static bool WaitForPreviewPane(Session explorer, int timeoutMS) + { + return explorer.WaitFor( + () => explorer.FindAll<Element>(By.Name(EmptyPreviewPaneText), timeoutMS: 250) + .Any(element => element.Width > 0 && element.Height > 0), + timeoutMS, + pollIntervalMS: 250); + } + + private static void EnsureExplorerForeground(Session explorer) + { + Assert.IsTrue( + WindowControl.WaitForForeground( + new IntPtr(explorer.WindowHandle), + ExplorerTimeoutMS, + requiredConsecutiveMatches: 3), + $"Explorer HWND {explorer.WindowHandle} was not the stable foreground window."); + } + + private static void SelectFile(Session explorer, string filePath) + { + var selection = ExplorerShell.SetSelectionAndWaitForStable( + new IntPtr(explorer.WindowHandle), + new[] { filePath }, + filePath, + ExplorerTimeoutMS, + requiredConsecutiveMatches: 4); + + Assert.IsTrue( + selection.Succeeded, + $"Explorer did not establish a stable selection for '{filePath}'. " + + $"Last focused path: '{selection.LastObservation?.FocusedPath ?? "<none>"}'."); + } + + private void SetExplorerViewAndWait( + Session explorer, + string fileName, + int iconSize, + string viewName, + int minimumItemHeight, + int maximumItemHeight) + { + Element? lastItem = null; + for (var attempt = 1; attempt <= 3; attempt++) + { + var view = ExplorerShell.SetViewModeAndIconSizeAndWait( + new IntPtr(explorer.WindowHandle), + ExplorerShell.ViewMode.Icons, + iconSize, + timeoutMS: 5_000); + if (!view.Succeeded) + { + TestContext.WriteLine( + $"Explorer did not report {viewName} on attempt {attempt}; " + + $"last Shell view: {view.LastObservation?.Mode}, icon size: {view.LastObservation?.IconSize}."); + continue; + } + + var applied = explorer.WaitFor( + () => + { + lastItem = FindVisibleFileItem(explorer, fileName, timeoutMS: 250); + return lastItem is not null && + lastItem.Height >= minimumItemHeight && + lastItem.Height <= maximumItemHeight; + }, + timeoutMS: 5_000, + pollIntervalMS: 250); + if (applied) + { + TestContext.WriteLine( + $"Explorer applied {viewName} ({iconSize}px) on attempt {attempt}; item bounds: " + + $"{lastItem!.Width}x{lastItem.Height}."); + return; + } + } + + Assert.Fail( + $"Explorer did not apply {viewName} after three shortcut attempts. " + + $"Expected item height {minimumItemHeight}..{maximumItemHeight}; " + + $"last bounds: {lastItem?.Width ?? 0}x{lastItem?.Height ?? 0}."); + } + + private string CaptureStableWindow(Session explorer, string name) + { + var previousPath = CaptureWindow(explorer, $"{name}-initial"); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + + while (DateTime.UtcNow < deadline) + { + Thread.Sleep(400); + var currentPath = CaptureWindow(explorer, name); + var difference = CalculateImageDifference(previousPath, currentPath, startXPercent: 0, startYPercent: 0); + if (difference < 0.25) + { + File.Delete(previousPath); + return currentPath; + } + + File.Delete(previousPath); + previousPath = currentPath; + } + + return previousPath; + } + + private string WaitForVisibleChange(Session explorer, string baselinePath, string name) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(PreviewTimeoutMS); + var lastDifference = 0d; + string? lastPath = null; + + while (DateTime.UtcNow < deadline) + { + Thread.Sleep(500); + var currentPath = CaptureWindow(explorer, name); + lastDifference = CalculateImageDifference( + baselinePath, + currentPath, + startXPercent: 55, + startYPercent: 18); + TestContext.WriteLine($"Preview-region pixel change: {lastDifference:F2}%."); + + if (lastDifference >= PreviewRegionDifferenceThreshold) + { + if (lastPath is not null) + { + File.Delete(lastPath); + } + + return currentPath; + } + + if (lastPath is not null) + { + File.Delete(lastPath); + } + + lastPath = currentPath; + } + + TestContext.AddResultFile(baselinePath); + if (lastPath is not null) + { + TestContext.AddResultFile(lastPath); + } + + Assert.Fail( + $"The Explorer preview region did not visibly render within {PreviewTimeoutMS / 1_000}s. " + + $"Expected at least {PreviewRegionDifferenceThreshold:F2}%; last sampled change was {lastDifference:F2}%."); + return null!; + } + + private ThumbnailCapture CaptureStableFileItem(Session explorer, string fileName, string name) + { + var previous = CaptureFileItem(explorer, fileName, $"{name}-initial"); + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(VisualStableTimeoutMS); + + while (DateTime.UtcNow < deadline) + { + Thread.Sleep(400); + var current = CaptureFileItem(explorer, fileName, name); + var difference = CalculateImageDifference(previous.Path, current.Path, 0, 0, requireSameSize: false); + if (previous.Width == current.Width && + previous.Height == current.Height && + difference < 0.25) + { + File.Delete(previous.Path); + return current; + } + + File.Delete(previous.Path); + previous = current; + } + + return previous; + } + + private ThumbnailCapture CaptureFileItem(Session explorer, string fileName, string name) + { + var item = FindVisibleFileItem(explorer, fileName, timeoutMS: 5_000); + Assert.IsNotNull(item, $"Explorer did not expose a visible item for '{fileName}'."); + item!.ScrollIntoView(); + item = FindVisibleFileItem(explorer, fileName, timeoutMS: 5_000); + Assert.IsNotNull(item, $"Explorer did not expose '{fileName}' after scrolling it into view."); + + var path = ArtifactPath(name); + EnsureExplorerForeground(explorer); + using (var bitmap = new Bitmap(item!.Width, item.Height)) + { + using var graphics = Graphics.FromImage(bitmap); + graphics.CopyFromScreen(item.X, item.Y, 0, 0, bitmap.Size); + bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png); + } + + return new ThumbnailCapture(path, item.Width, item.Height); + } + + private static Element? FindVisibleFileItem(Session explorer, string fileName, int timeoutMS) + { + var displayName = Path.GetFileNameWithoutExtension(fileName); + return explorer.FindAll<Element>(By.Name(displayName), timeoutMS) + .Where(element => element.Width > 0 && element.Height > 0) + .Where(element => + element.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) || + element.Name.Equals(displayName, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(element => element.ControlType.Equals("ListItem", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(element => element.Width * element.Height) + .FirstOrDefault(); + } + + private string CaptureWindow(Session explorer, string name) + { + var path = ArtifactPath(name); + explorer.ScreenshotVisibleWindow(path); + return path; + } + + private string ArtifactPath(string name, string extension = ".png") + { + var currentTestName = TestContext.TestName ?? "unknown-test"; + var testName = string.Concat( + currentTestName.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '-' : character)); + var directory = Path.Combine( + FindStableResultsRoot(), + "FileExplorerAddons", + testName); + Directory.CreateDirectory(directory); + return Path.Combine(directory, $"{name}-{Guid.NewGuid():N}{extension}"); + } + + private string FindStableResultsRoot() + { + var candidate = TestContext.TestResultsDirectory ?? TestContext.TestRunResultsDirectory; + var directory = string.IsNullOrWhiteSpace(candidate) ? null : new DirectoryInfo(candidate); + while (directory is not null) + { + if (directory.Name.Equals("TestResults", StringComparison.OrdinalIgnoreCase)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + return Path.GetTempPath(); + } + + private static double CalculateImageDifference( + string baselinePath, + string currentPath, + int startXPercent, + int startYPercent, + bool requireSameSize = true) + { + using var baseline = new Bitmap(baselinePath); + using var current = new Bitmap(currentPath); + if (baseline.Size != current.Size) + { + if (requireSameSize) + { + Assert.Fail("Explorer changed size while waiting for visual content."); + } + + return 100; + } + + var changedSamples = 0; + var totalSamples = 0; + var startX = baseline.Width * startXPercent / 100; + var startY = baseline.Height * startYPercent / 100; + + for (var y = startY; y < baseline.Height; y += 3) + { + for (var x = startX; x < baseline.Width; x += 3) + { + var before = baseline.GetPixel(x, y); + var after = current.GetPixel(x, y); + var colorDelta = Math.Abs(before.R - after.R) + + Math.Abs(before.G - after.G) + + Math.Abs(before.B - after.B); + if (colorDelta >= 45) + { + changedSamples++; + } + + totalSamples++; + } + } + + return totalSamples == 0 ? 0 : changedSamples * 100d / totalSamples; + } + + private static void AssertThumbnailSizes( + ThumbnailCapture extraLarge, + ThumbnailCapture large, + ThumbnailCapture medium, + string extension) + { + Assert.IsTrue( + extraLarge.Height >= 180 && + extraLarge.Height > large.Height && + large.Height > medium.Height, + $"Explorer did not apply descending icon sizes for {extension}. " + + $"Extra large: {extraLarge.Width}x{extraLarge.Height}; " + + $"large: {large.Width}x{large.Height}; medium: {medium.Width}x{medium.Height}."); + } + + private static void AssertImageHasVisualDetail(string imagePath, string extension) + { + using var image = new Bitmap(imagePath); + var colorBuckets = new HashSet<int>(); + + for (var y = 0; y < image.Height; y += 3) + { + for (var x = 0; x < image.Width; x += 3) + { + var color = image.GetPixel(x, y); + colorBuckets.Add(((color.R >> 5) << 6) | ((color.G >> 5) << 3) | (color.B >> 5)); + } + } + + Assert.IsTrue( + colorBuckets.Count >= 6, + $"The captured {extension} Explorer item has only {colorBuckets.Count} sampled color buckets; " + + "the thumbnail appears blank or generic."); + } + + private static string? WaitForProviderLog(string logDirectory, string expectedText, int timeoutMS) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (DateTime.UtcNow < deadline) + { + foreach (var path in Directory.Exists(logDirectory) + ? Directory.GetFiles(logDirectory, "*.log", SearchOption.TopDirectoryOnly) + : Array.Empty<string>()) + { + var contents = ReadAllTextWithRetry(path); + if (contents.Contains(expectedText, StringComparison.OrdinalIgnoreCase)) + { + return path; + } + } + + Thread.Sleep(100); + } + + return null; + } + + private static string LocalLowHandlerLogDirectory(string handlerFolder) + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "AppData", + "LocalLow", + "Microsoft", + "PowerToys", + "logs", + "FileExplorer_localLow", + handlerFolder); + } + + private static string ReadAllTextWithRetry(string path) + { + for (var attempt = 0; attempt < 20; attempt++) + { + try + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + catch (IOException) when (attempt < 19) + { + Thread.Sleep(50); + } + } + + return string.Empty; + } + + private string TestAssetPath(string assetName) + { + var path = Path.GetFullPath(Path.Combine("TestAssets", assetName)); + Assert.IsTrue(File.Exists(path), $"File Explorer Add-ons test asset does not exist: {path}"); + return path; + } + + private string CreateTemporaryFolder() + { + var folder = Path.Combine(Path.GetTempPath(), "PowerToys-FileExplorerAddons", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + temporaryFolders.Add(folder); + return folder; + } + + private static void DeleteDirectoryWithRetry(string folder) + { + for (var attempt = 0; attempt < 20 && Directory.Exists(folder); attempt++) + { + try + { + Directory.Delete(folder, recursive: true); + } + catch (IOException) + { + Thread.Sleep(100); + } + catch (UnauthorizedAccessException) + { + Thread.Sleep(100); + } + } + } + + private sealed class SandboxThumbnailRegistration : IDisposable + { + private readonly bool active; + private readonly string providerPath = string.Empty; + private readonly string associationPath = string.Empty; + private readonly RegistryTreeSnapshot? userProvider; + private readonly RegistryTreeSnapshot? userAssociation; + private readonly RegistryTreeSnapshot? machineProvider; + private readonly RegistryTreeSnapshot? machineAssociation; + private readonly RegistryTreeSnapshot? machineIsolation; + + private SandboxThumbnailRegistration() + { + } + + private SandboxThumbnailRegistration( + string providerPath, + string associationPath, + RegistryTreeSnapshot userProvider, + RegistryTreeSnapshot userAssociation, + RegistryTreeSnapshot machineProvider, + RegistryTreeSnapshot machineAssociation, + RegistryTreeSnapshot machineIsolation) + { + active = true; + this.providerPath = providerPath; + this.associationPath = associationPath; + this.userProvider = userProvider; + this.userAssociation = userAssociation; + this.machineProvider = machineProvider; + this.machineAssociation = machineAssociation; + this.machineIsolation = machineIsolation; + } + + public static SandboxThumbnailRegistration Create(string extension, string providerClsid) + { + if (!Environment.UserName.Equals("WDAGUtilityAccount", StringComparison.OrdinalIgnoreCase)) + { + return new SandboxThumbnailRegistration(); + } + + var providerPath = $@"Software\Classes\CLSID\{providerClsid}"; + var associationPath = $@"Software\Classes\{extension}\shellex\{ThumbnailHandlerShellExtension}"; + var userProvider = RegistryTreeSnapshot.Capture(Registry.CurrentUser, providerPath); + var userAssociation = RegistryTreeSnapshot.Capture(Registry.CurrentUser, associationPath); + var machineProvider = RegistryTreeSnapshot.Capture(Registry.LocalMachine, providerPath); + var machineAssociation = RegistryTreeSnapshot.Capture(Registry.LocalMachine, associationPath); + var machineIsolation = RegistryTreeSnapshot.Capture(Registry.LocalMachine, ThumbnailIsolationRegistryPath); + + Assert.IsTrue(userProvider.Exists, $"Sandbox bridge could not find HKCU\\{providerPath}."); + Assert.IsTrue(userAssociation.Exists, $"Sandbox bridge could not find HKCU\\{associationPath}."); + + try + { + userProvider.Restore(Registry.LocalMachine, providerPath); + userAssociation.Restore(Registry.LocalMachine, associationPath); + using (var isolationKey = Registry.LocalMachine.CreateSubKey(ThumbnailIsolationRegistryPath, writable: true)) + { + Assert.IsNotNull(isolationKey, "Could not create the machine Shell thumbnail isolation key in Sandbox."); + isolationKey!.SetValue(DisableProcessIsolationValueName, 1, RegistryValueKind.DWord); + } + + Registry.CurrentUser.DeleteSubKeyTree(providerPath, throwOnMissingSubKey: false); + Registry.CurrentUser.DeleteSubKeyTree(associationPath, throwOnMissingSubKey: false); + NotifyShellAssociationsChanged(); + + return new SandboxThumbnailRegistration( + providerPath, + associationPath, + userProvider, + userAssociation, + machineProvider, + machineAssociation, + machineIsolation); + } + catch + { + machineIsolation.Restore(Registry.LocalMachine, ThumbnailIsolationRegistryPath); + machineAssociation.Restore(Registry.LocalMachine, associationPath); + machineProvider.Restore(Registry.LocalMachine, providerPath); + userAssociation.Restore(Registry.CurrentUser, associationPath); + userProvider.Restore(Registry.CurrentUser, providerPath); + NotifyShellAssociationsChanged(); + throw; + } + } + + public void Dispose() + { + if (!active) + { + return; + } + + machineIsolation!.Restore(Registry.LocalMachine, ThumbnailIsolationRegistryPath); + machineAssociation!.Restore(Registry.LocalMachine, associationPath); + machineProvider!.Restore(Registry.LocalMachine, providerPath); + userAssociation!.Restore(Registry.CurrentUser, associationPath); + userProvider!.Restore(Registry.CurrentUser, providerPath); + NotifyShellAssociationsChanged(); + } + } + + private sealed class RegistryTreeSnapshot + { + private readonly List<RegistryValueSnapshot> values = new(); + private readonly Dictionary<string, RegistryTreeSnapshot> subKeys = new(StringComparer.OrdinalIgnoreCase); + + private RegistryTreeSnapshot(bool exists) + { + Exists = exists; + } + + public bool Exists { get; } + + public static RegistryTreeSnapshot Capture(RegistryKey root, string path) + { + using var key = root.OpenSubKey(path, writable: false); + return key is null ? new RegistryTreeSnapshot(false) : CaptureKey(key); + } + + public void Restore(RegistryKey root, string path) + { + root.DeleteSubKeyTree(path, throwOnMissingSubKey: false); + if (!Exists) + { + return; + } + + using var key = root.CreateSubKey(path, writable: true) ?? + throw new InvalidOperationException($"Could not restore registry key '{path}'."); + RestoreKey(key); + } + + private static RegistryTreeSnapshot CaptureKey(RegistryKey key) + { + var snapshot = new RegistryTreeSnapshot(true); + foreach (var valueName in key.GetValueNames()) + { + var value = key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames); + if (value is not null) + { + snapshot.values.Add(new RegistryValueSnapshot(valueName, value, key.GetValueKind(valueName))); + } + } + + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, writable: false); + if (subKey is not null) + { + snapshot.subKeys[subKeyName] = CaptureKey(subKey); + } + } + + return snapshot; + } + + private void RestoreKey(RegistryKey key) + { + foreach (var value in values) + { + key.SetValue(value.Name, value.Value, value.Kind); + } + + foreach (var (subKeyName, snapshot) in subKeys) + { + using var subKey = key.CreateSubKey(subKeyName, writable: true) ?? + throw new InvalidOperationException($"Could not restore registry subkey '{subKeyName}'."); + snapshot.RestoreKey(subKey); + } + } + } + + private sealed record RegistryValueSnapshot(string Name, object Value, RegistryValueKind Kind); + + private static void NotifyShellAssociationsChanged() + { + SHChangeNotify(0x08000000, 0, IntPtr.Zero, IntPtr.Zero); + } + + [DllImport("shell32.dll")] + private static extern void SHChangeNotify(long eventId, uint flags, IntPtr item1, IntPtr item2); + + private sealed record ThumbnailCapture(string Path, int Width, int Height); +} diff --git a/src/modules/previewpane/PreviewPane.UITests/PreviewPane.UITests.csproj b/src/modules/previewpane/PreviewPane.UITests/PreviewPane.UITests.csproj new file mode 100644 index 000000000000..bbc53b373667 --- /dev/null +++ b/src/modules/previewpane/PreviewPane.UITests/PreviewPane.UITests.csproj @@ -0,0 +1,52 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <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.PreviewPane.UITests</RootNamespace> + <AssemblyName>PreviewPane.UITests</AssemblyName> + <ApplicationManifest>app.manifest</ApplicationManifest> + <IsTestingPlatformApplication>true</IsTestingPlatformApplication> + <EnableMSTestRunner>true</EnableMSTestRunner> + <GenerateDocumentationFile>false</GenerateDocumentationFile> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\PreviewPane.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" /> + </ItemGroup> + + <ItemGroup> + <Content Include="$(RepoRoot)README.md" Link="TestAssets\README.md"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="$(RepoRoot)src\modules\previewpane\UnitTests-SvgPreviewHandler\HelperFiles\file1.svg" Link="TestAssets\sample.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="$(RepoRoot)src\modules\previewpane\UnitTests-PdfPreviewHandler\HelperFiles\sample.pdf" Link="TestAssets\sample.pdf"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="$(RepoRoot)src\modules\previewpane\UnitTests-GcodePreviewHandler\HelperFiles\sample_JPG.gcode" Link="TestAssets\sample.gcode"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="$(RepoRoot)src\modules\previewpane\UnitTests-StlThumbnailProvider\HelperFiles\sample.stl" Link="TestAssets\sample.stl"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="$(RepoRoot)src\runner\main.cpp" Link="TestAssets\main.cpp"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/previewpane/PreviewPane.UITests/app.manifest b/src/modules/previewpane/PreviewPane.UITests/app.manifest new file mode 100644 index 000000000000..4107ae5a544f --- /dev/null +++ b/src/modules/previewpane/PreviewPane.UITests/app.manifest @@ -0,0 +1,10 @@ +<?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="PreviewPane.UITests.app" /> + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> + </windowsSettings> + </application> +</assembly> \ No newline at end of file