diff --git a/.github/skills/ui-tests-migration/references/ci-stability.md b/.github/skills/ui-tests-migration/references/ci-stability.md index 6f28879a6e..39e7f5981e 100644 --- a/.github/skills/ui-tests-migration/references/ci-stability.md +++ b/.github/skills/ui-tests-migration/references/ci-stability.md @@ -211,6 +211,14 @@ Some state lives only in a long-running process. Peek's pinned geometry must pre 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. +**Restarting PowerToys is not a way to apply settings.** Modules watch their own `settings.json` and +hot-reload it, so seeding the file is enough. A per-test `RestartScope()` on top of the base class's +launch starts the runner twice per test — pure runtime — and worse, it converts "the user changed a +setting while the module ran" into "the module started with that setting", hiding live +reconfiguration defects. Restart only when the restart is the behaviour under test (state surviving a +restart), when the enabled-module set changes, or to recover from a terminal failure. See +[patterns-and-pitfalls.md](patterns-and-pitfalls.md) Recipe 17. + --- ## Principle 6 — Everything on-screen, DPI-correct, from a clean profile @@ -297,6 +305,7 @@ likely extra CI iteration. - [ ] 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 +- [ ] Module settings are seeded and hot-reloaded, not applied by relaunching PowerToys (P5 / Recipe 17) - [ ] 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 diff --git a/.github/skills/ui-tests-migration/references/nuget-runtime-pack-cache.md b/.github/skills/ui-tests-migration/references/nuget-runtime-pack-cache.md new file mode 100644 index 0000000000..32de288a3d --- /dev/null +++ b/.github/skills/ui-tests-migration/references/nuget-runtime-pack-cache.md @@ -0,0 +1,41 @@ +# NuGet runtime-pack cache misses + +## Symptom + +CI reports `NU1102` for an implicit `Microsoft.*.Runtime.*` or `Microsoft.*.Host.*` package even +though the PR did not change package configuration. + +## Cause + +PowerToys CI uses a floating .NET SDK. A new SDK patch can request an exact framework-pack version +that `PowerToysPublicDependencies` has not cached from its upstream source yet. + +Do not pin the SDK, add explicit framework-pack references, or downgrade unrelated packages to fix +this feed-state problem. + +## Resolution + +1. Confirm the CI SDK version and that the PR did not change `Directory.Packages.props`, + `nuget.config`, or SDK selection. +2. Use that SDK locally and authenticate through the Azure Artifacts credential provider. The + identity needs **Feed and Upstream Reader (Collaborator)** permission or higher. +3. Restore the failing self-contained projects for both architectures so the feed caches the packs: + + ```pwsh + dotnet restore -p:Platform=x64 --interactive --no-cache + dotnet restore -p:Platform=ARM64 --interactive --no-cache + ``` + +4. Rerun NuGet verification: + + ```pwsh + .\.pipelines\verifyNugetPackages.ps1 -solution .\PowerToys.slnx + ``` + +## Follow-on dependency audit + +Once the packs are cached, CI may expose that centrally managed .NET packages are still on the +previous patch. If the deps audit groups framework assemblies under both patch versions, advance the +repository's managed .NET package set together, then rerun NuGet verification and the deps audit. + +Never place a PAT in a command or checked-in NuGet configuration. \ No newline at end of file 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 ff1fbb603a..deae0debe3 100644 --- a/.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md +++ b/.github/skills/ui-tests-migration/references/patterns-and-pitfalls.md @@ -406,6 +406,175 @@ Rules for using it: - **Name it after the control, not the test**, and keep it stable; it is now part of the module's automation contract. +## Recipe 17 — Change a module's settings WITHOUT restarting PowerToys + +PowerToys modules watch their own `settings.json` and hot-reload it — it is the house pattern, not a +one-off. The C# modules keep an `IFileSystemWatcher` in their `UserSettings`/settings service +(ColorPicker, Peek, Hosts, Image Resizer, PowerToys Run, Quick Accent, Text Extractor, Awake, Mouse +utilities, Advanced Paste …); the C++ side does the same, e.g. FancyZones installs a `FileWatcher` in +its settings singleton that broadcasts `WM_PRIV_SETTINGS_CHANGED`, driving `LoadSettings()` and +notifying every observer (`FancyZonesLib/Settings.cpp`). So a suite that needs different module +options per test should **write the file and carry on** — not kill and relaunch the runner. + +```csharp +// Per-test arrangement: seed the module's own settings, let the watcher pick them up, continue. +SettingsConfigHelper.UpdateModuleSettings("FancyZones", DefaultSettings, s => { /* set properties */ }); +Thread.Sleep(2_000); // bounded settle for the file watcher — NOT RestartScope() +``` + +Two reasons this matters, and the second is the important one: + +- **Speed.** A runner kill + relaunch costs 60–90 s on a loaded machine or VM. `UITestBase` already + launches the scope for every test, so a `RestartScope()` in your own `[TestInitialize]` starts + PowerToys **twice per test**. On a 17-test FancyZones suite that was ~40% of total wall time + (~75 s × 17) for zero coverage. +- **Fidelity.** A relaunch converts "the user changed this setting while the module was running" into + "the module started with this setting". That is not the scenario users hit, and it **hides live + reconfiguration bugs** — a module that ignores a settings change until restart still passes. + +Restart only when the restart is genuinely part of the scenario: + +| Restart | Why | +|---|---| +| Changing which modules are **enabled** | `enableModules` / the global `settings.json` `enabled` map is read when the runner launches. | +| Asserting state **survives a restart** | e.g. per-virtual-desktop layout persistence — the restart is the behaviour under test. | +| Recovering from a **terminal** failure | A hung/dead scope, after patient readiness has already failed (see ci-stability Principle 5). | + +The same reasoning applies to the module's data files: prefer seeding them and letting the module or +its editor re-read them over bouncing the process, and assert on the file the product writes back +(FancyZones' `applied-layouts.json` / `app-zone-history.json`) rather than on the restart. + +**Expect to inherit state the restart used to reset — and be ready to put the restart back.** Dropping +the per-test relaunch turns a long-lived module process into part of the fixture, so anything the +module remembers between tests becomes yours to sequence. FancyZones is the worked example: its +`ToggleEditor` keeps a terminate-editor handle, and while that handle is alive the toggle event means +*close*, not *open*. With a per-test restart that state was silently reset; without one, the editor +open is swallowed and the suite collapsed from **14/17 to 3/17**. Closing the editor explicitly, +waiting for the process to be gone, settling, and retrying the signal did **not** recover it, so that +suite keeps a restart — scoped to resetting the editor state, not to applying settings: + +```csharp +new FancyZonesSettingsSeed()./* … */.Apply(); // settings hot-reload; no restart needed for these +RestartScope(); // ONLY to reset the module's editor-toggle state +``` + +Practical rule: default to no restart, and when you remove one, verify with a **full-suite** run +rather than a focused test — cross-test state only shows up in sequence. If the pass rate drops, +first try making the module's own state explicit (close-and-confirm rather than kill, wait for the +product's acknowledgement, re-read state before retrying a stateful trigger); if that still fails, +keep the restart and write down which state forces it. The failures this exposes are real ones users +can hit — worth reporting to the module owners rather than only working around. + +When a test must restart the runner, wait for its single-instance module process to exit too. Waiting +only for the runner PID can leave the child holding its mutex long enough that the replacement runner +tracks a short-lived duplicate; explicitly stop-and-wait the module before relaunching. + +--- + +## Recipe 18 — Catch a short-lived window (flash, toast, overlay): hook, don't poll + +**Do not poll for a transient window. Hook `EVENT_OBJECT_SHOW`.** + +A poll can only observe a window that outlives its sample interval, so it cannot distinguish "never +shown" from "shown and hidden again immediately" — and you cannot close that gap by sampling faster, +because the probe then contends for the window manager it is inspecting. A `WinEvent` hook is notified +of every show regardless of how briefly the window survives: + +```csharp +using var watcher = new WindowShowWatcher("FancyZones_ZonesOverlay"); +trigger(); +bool shown = watcher.Wait(5_000); +Step(this, $"Overlay window events: {string.Join(", ", watcher.Events)}"); // evidence either way +``` + +This is not a theoretical preference. FancyZones' zone flash is documented as 700 ms +(`FlashZonesDurationMillis`) and the hook measured it at **687 ms** — comfortably longer than any +sample interval used. Polling for it at **12 ms, 58 ms and 500 ms still reported nothing**, across +both isolated and full-suite runs, and sent the investigation through four wrong explanations +(expensive probe → starved watcher → observer effect → settings clobber). The hook answered on the +first run and the test went from permanently red to passing in 14.5 s. + +The eventual suspect for the polling failure was the probe's own `FindWindowEx` chaining: when a +product **pools** windows of one class, a chained `FindWindowEx(NULL, previous, class, NULL)` walk is +easy to get subtly wrong and end up only ever inspecting the first match — which may be the stale +pooled window, permanently hidden. `WindowControl` now enumerates with `EnumWindows` and compares +class names instead. That bug is fixed, but the lesson stands: an event hook has no sample interval +and no enumeration order to get wrong. + +Two supporting rules, both learned the hard way here: + +- **Timestamp with the event's own `dwmsEventTime`, not `DateTime.Now` in your callback.** A hook + thread pumps its queue on an interval, so a locally-taken timestamp measures your pump latency. Mine + reported SHOW and HIDE in the same millisecond and I briefly concluded the product's animation was + broken; the OS-supplied timestamps showed a perfectly healthy 687 ms. +- **Validate the detector positively before trusting a negative.** A probe that has only ever returned + `false` has not been tested. Assert it returns `true` at a moment the thing is provably present — + for the zones overlay, mid-drag, where the dragged window's alpha of 127 independently proves the + zones are on screen. Note this control *passed* while the probe was still subtly broken, so treat a + single positive as necessary, not sufficient. + +> If you must poll something (a *stable* window, not a transient one), keep the probe cheap: +> `WindowControl.IsAnyWindowOfClassVisible` / `AnyWindowOfClassExists` use `FindWindowEx` + +> `IsWindowVisible`, whereas `EnumerateAllWindows()` reads every window's title through +> `GetWindowTextW`, a cross-process `WM_GETTEXT` that blocks on a busy owner. + +> Existence is not readiness when the product **pools** windows. FancyZones' `WorkArea.cpp` keeps a +> per-process pool (`FreeZonesOverlayWindow`/`Reusing ZonesOverlay window from pool`), so a window of +> that class survives the work area that owned it. `AnyWindowOfClassExists` is a meaningful startup +> gate only against a **freshly restarted** module, where the pool is still empty. + +## Recipe 19 — Tell a swallowed input event apart from a product bug + +When an injected key produces no reaction, the interesting question is whether the product *ignored* +it or never *received* it — modules install low-level keyboard hooks, and a hook that returns 1 +removes the event from the input stream for everyone, including the module's own raw-input listener. + +```csharp +KeyboardHelper.PressKey(Key.LShift); // inject one physical Shift key; generic VK_SHIFT is ambiguous +// false => the key never reached the system's async key state, i.e. some LL hook swallowed it +var reachedTheSystem = KeyboardHelper.IsKeyDown(Key.Shift); +``` + +Use `LShift` when the test must exercise a physical left/right-key branch in a low-level hook; keep +`Shift` for the aggregate state query. This makes the injected path explicit, but it does not repair +incorrect product state logic by itself. + +Do not gate the hook on a derived "snapping active" flag when the key itself activates snapping. +FancyZones originally checked `DraggingState::IsDragging()`, which is false in Shift-to-activate +mode until Shift is processed; gate on the active window move loop instead. Also apply the snapping +mode transition before calculating the first highlighted zone: if the transition resets highlight +state afterward, the first modifier-triggered update is discarded and a second mouse move becomes +an accidental requirement. + +For a stateful drag, retry the **whole gesture**: reacquire the same HWND and foreground, grab its +title bar, move, change modifier state, wait, and drop. Repeating only the modifier after a missed +grab just repeats input over the desktop. Avoid movement-based readiness probes too: a cursor jiggle +can move out of the selected zone or hide it. If the modifier callback already schedules a product +update, wait without moving; keep the modifier held until the asynchronous move-end signal records +the authoritative snap result. + +Explorer can expose its top-level HWND before its title bar finishes rendering. Before mouse-down, +verify the root HWND under the intended screen point is the target. After any failed grab, release the +button, restore/recenter the same HWND, wait for stable bounds and foreground, and recompute the next +candidate from those current bounds. Reusing points derived before a failed desktop-selection drag +keeps clicking behind a window that has already moved. + +Pair it with a **control gesture** that drives the same state machine through a path where nothing can +swallow the key — usually by reordering the gesture: + +| Gesture | Result | Meaning | +|---|---|---| +| Modifier pressed **during** the drag | no effect, `IsKeyDown` false | the event was consumed before delivery | +| Modifier held **before** the drag starts | correct behaviour | the product's state logic is fine | + +Two observations, one run, and the failure message can name the defect instead of the symptom. This is +how FancyZones' "Shift cannot deactivate zones once they are showing" was localized to the bare-Shift +swallow in `FancyZones.cpp::OnKeyDown`. + +> This host may not be able to inject at all: if `GetForegroundWindow()` returns 0 (locked or secure +> desktop), `SendInput`/`keybd_event` fail with `ERROR_ACCESS_DENIED` (5) and every input experiment +> silently does nothing. Check that before concluding anything from an input test run outside the VM. + --- ## Pitfalls @@ -458,12 +627,15 @@ Rules for using it: hook asynchronously, so the first chord is easily lost. Settle ~1.5s after the toggle, then re-send the chord and poll for the window, for several attempts (SKILL Recipe 4; the ScreenRuler `SendShortcutUntilVisible` helper is the reference). -15. **Per-test cold relaunch amplifies flakiness.** By default each `[TestMethod]` kills + relaunches - the runner, so every test pays the startup + hook-arming cost. For a suite of cheap cases against - one page, consider `ReuseScopeAcrossTests => true` (one launch per class). Content-dependent - measurements (spacing edge-detection) also vary with what's under the cursor — assert on **format** - (regex) unless the gesture is content-independent (a free-form drag like Bounds), where an exact - value is safe. +15. **Per-test cold relaunch amplifies flakiness — and never relaunch just to change a setting.** By + default each `[TestMethod]` kills + relaunches the runner, so every test pays the startup + + hook-arming cost. For a suite of cheap cases against one page, consider + `ReuseScopeAcrossTests => true` (one launch per class). Do **not** add a second relaunch of your + own (`RestartScope()` in a derived `[TestInitialize]`) to make seeded module settings take effect: + modules hot-reload their own `settings.json`, so the restart only doubles the runtime and masks + live-reconfiguration bugs — see Recipe 17. Content-dependent measurements (spacing edge-detection) + also vary with what's under the cursor — assert on **format** (regex) unless the gesture is + content-independent (a free-form drag like Bounds), where an exact value is safe. 16. **Coordinate gestures break when the window/cursor is off-screen — and it only shows on CI.** A `WindowSize` preset that resized but kept its old top-left could push the Settings window (and the measurement area) partially off a same-sized 1920×1080 CI display, so the gesture landed off-screen diff --git a/.github/skills/ui-tests-migration/references/project-setup.md b/.github/skills/ui-tests-migration/references/project-setup.md index 57dcd20421..b279db68da 100644 --- a/.github/skills/ui-tests-migration/references/project-setup.md +++ b/.github/skills/ui-tests-migration/references/project-setup.md @@ -193,6 +193,8 @@ $exe = "$PWD\x64\Debug\tests\.UITests.Next\net10.0-windows10.0.26100.0\< ``` - On build failure, read `build...errors.log` next to the project. +- For CI runtime-pack restore or dependency-audit failures, see + [NuGet runtime-pack cache misses](nuget-runtime-pack-cache.md). - `winapp.exe` is a **run-time** prerequisite only (`winget install Microsoft.winappcli`, or set `WINAPP_CLI_PATH`). A migration that compiles clean is valid even where the CLI/desktop is absent; say so and list coverage. diff --git a/PowerToys.slnx b/PowerToys.slnx index 3596122996..50a3cf8518 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -458,10 +458,18 @@ + + + + + + + + diff --git a/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs b/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs index 88648e8916..6d396038c8 100644 --- a/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs +++ b/src/common/UITestAutomation.Next.UnitTests/SettingsConfigHelperTests.cs @@ -129,6 +129,82 @@ public class SettingsConfigHelperTests } } + [TestMethod] + public void PreserveFirstRunSettingsRestoresExistingFiles() + { + var root = CreateTemporaryDirectory(); + var settingsPath = Path.Combine(root, "settings.json"); + var oobePath = Path.Combine(root, "oobe_settings.json"); + var originalSettings = new byte[] { 1, 2, 3 }; + var originalOobe = new byte[] { 4, 5, 6 }; + + try + { + File.WriteAllBytes(settingsPath, originalSettings); + File.WriteAllBytes(oobePath, originalOobe); + + using (SettingsConfigHelper.PreserveFirstRunSettings(root)) + { + File.WriteAllText(settingsPath, "changed settings"); + File.WriteAllText(oobePath, "changed oobe"); + } + + CollectionAssert.AreEqual(originalSettings, File.ReadAllBytes(settingsPath)); + CollectionAssert.AreEqual(originalOobe, File.ReadAllBytes(oobePath)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public void PreserveFirstRunSettingsDeletesCreatedFiles() + { + var root = CreateTemporaryDirectory(); + var settingsPath = Path.Combine(root, "settings.json"); + var oobePath = Path.Combine(root, "oobe_settings.json"); + + try + { + using (SettingsConfigHelper.PreserveFirstRunSettings(root)) + { + File.WriteAllText(settingsPath, "created settings"); + File.WriteAllText(oobePath, "created oobe"); + } + + Assert.IsFalse(File.Exists(settingsPath)); + Assert.IsFalse(File.Exists(oobePath)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [TestMethod] + public void PreserveFirstRunSettingsRestoresFirstSnapshotWhenSecondFails() + { + var root = CreateTemporaryDirectory(); + var settingsPath = Path.Combine(root, "settings.json"); + var oobePath = Path.Combine(root, "oobe_settings.json"); + var originalSettings = new byte[] { 1, 2, 3 }; + + try + { + File.WriteAllBytes(settingsPath, originalSettings); + File.WriteAllText(oobePath, "locked"); + + using var lockedOobe = new FileStream(oobePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + Assert.ThrowsExactly(() => SettingsConfigHelper.PreserveFirstRunSettings(root)); + CollectionAssert.AreEqual(originalSettings, File.ReadAllBytes(settingsPath)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + private static string CreateTemporaryDirectory() { var path = Path.Combine(Path.GetTempPath(), "PowerToys-UITestAutomationNext-UnitTests", Guid.NewGuid().ToString("N")); diff --git a/src/common/UITestAutomation.Next/KeyboardHelper.cs b/src/common/UITestAutomation.Next/KeyboardHelper.cs index 9c7120fadb..eeab43a831 100644 --- a/src/common/UITestAutomation.Next/KeyboardHelper.cs +++ b/src/common/UITestAutomation.Next/KeyboardHelper.cs @@ -12,6 +12,7 @@ public enum Key : byte { Ctrl = 0x11, Shift = 0x10, + LShift = 0xA0, Alt = 0x12, LWin = 0x5B, Tab = 0x09, @@ -96,6 +97,9 @@ public static class KeyboardHelper private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); #pragma warning restore SA1300 + [DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int vKey); + private const uint KEYEVENTF_KEYUP = 0x2; private const uint KEYEVENTF_EXTENDEDKEY = 0x1; private const byte VK_LWIN = 0x5B; @@ -119,7 +123,8 @@ public static class KeyboardHelper winDown = true; break; case Key.Ctrl: chord.Append('^'); break; - case Key.Shift: chord.Append('+'); break; + case Key.Shift: + case Key.LShift: chord.Append('+'); break; case Key.Alt: chord.Append('%'); break; case Key.Esc: chord.Append("{ESC}"); break; case Key.Enter: chord.Append("{ENTER}"); break; @@ -171,6 +176,13 @@ public static class KeyboardHelper } } + /// + /// Whether is currently held, per the system's async key state. A key that a + /// low-level keyboard hook swallowed never reaches this state, so this also tells a test whether an + /// injected key was consumed by another process's hook. + /// + public static bool IsKeyDown(Key key) => (GetAsyncKeyState((byte)key) & 0x8000) != 0; + /// Press (and hold) a key via keybd_event. Pair with . public static void PressKey(Key key) => keybd_event((byte)key, 0, IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u, UIntPtr.Zero); diff --git a/src/common/UITestAutomation.Next/ModuleConfigData.cs b/src/common/UITestAutomation.Next/ModuleConfigData.cs index 3087407ebf..ce105b664c 100644 --- a/src/common/UITestAutomation.Next/ModuleConfigData.cs +++ b/src/common/UITestAutomation.Next/ModuleConfigData.cs @@ -61,7 +61,7 @@ internal static class ModulePaths [PowerToysModule.PowerToysSettings] = new("PowerToys.Settings.exe", "WinUI3Apps", "PowerToys.Settings", "PowerToys Settings"), [PowerToysModule.Runner] = new("PowerToys.exe", null, "PowerToys", "PowerToys"), [PowerToysModule.ColorPicker] = new("PowerToys.ColorPickerUI.exe", null, "PowerToys.ColorPickerUI", "PowerToys.ColorPickerUI"), - [PowerToysModule.FancyZonesEditor] = new("PowerToys.FancyZonesEditor.exe", null, "PowerToys.FancyZonesEditor", "FancyZones Layout"), + [PowerToysModule.FancyZonesEditor] = new("PowerToys.FancyZonesEditor.exe", null, "PowerToys.FancyZonesEditor", "FancyZones Editor"), [PowerToysModule.Hosts] = new("PowerToys.Hosts.exe", "WinUI3Apps", "PowerToys.Hosts", "Hosts File Editor"), [PowerToysModule.Workspaces] = new("PowerToys.WorkspacesEditor.exe", null, "PowerToys.WorkspacesEditor", "Workspaces Editor"), [PowerToysModule.PowerRename] = new("PowerToys.PowerRename.exe", "WinUI3Apps", "PowerToys.PowerRename", "PowerRename"), diff --git a/src/common/UITestAutomation.Next/NamedEventHelper.cs b/src/common/UITestAutomation.Next/NamedEventHelper.cs new file mode 100644 index 0000000000..a01b5321b8 --- /dev/null +++ b/src/common/UITestAutomation.Next/NamedEventHelper.cs @@ -0,0 +1,62 @@ +// 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; + +/// +/// Signals the named events PowerToys modules listen on (see common/interop/shared_constants.h). +/// +/// +/// Many module actions that a test would otherwise drive through the Settings UI are also exposed as +/// a named event the runner or module waits on. Signalling it is a single kernel call, where the UI +/// route costs several winapp.exe invocations, each of which walks the Settings UIA tree and +/// can take tens of seconds on a loaded machine. Use this when the Settings interaction is a means to +/// an end rather than the behaviour under test. +/// +public static class NamedEventHelper +{ + /// Toggles the FancyZones layout editor open/closed. + public const string FancyZonesEditorToggle = @"Local\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14"; + + /// Set an existing named event. Returns false when no module currently owns it. + public static bool TrySignal(string name) + { + try + { + if (!EventWaitHandle.TryOpenExisting(name, out var handle)) + { + return false; + } + + using (handle) + { + return handle.Set(); + } + } + catch (Exception) + { + return false; + } + } + + /// Wait until a module has created the named event, then signal it. + public static bool WaitAndSignal(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS); + while (true) + { + if (TrySignal(name)) + { + return true; + } + + if (DateTime.UtcNow >= deadline) + { + return false; + } + + Thread.Sleep(pollIntervalMS); + } + } +} diff --git a/src/common/UITestAutomation.Next/ScreenCapture.cs b/src/common/UITestAutomation.Next/ScreenCapture.cs index 74f54b15e2..5e3f2f1dab 100644 --- a/src/common/UITestAutomation.Next/ScreenCapture.cs +++ b/src/common/UITestAutomation.Next/ScreenCapture.cs @@ -9,12 +9,13 @@ using System.Runtime.InteropServices; namespace Microsoft.PowerToys.UITest.Next; /// -/// Captures the full desktop (including the mouse cursor) to a PNG. Used only by the pipeline path -/// of , which fires on a one-second timer so a -/// failed CI run carries a frame-by-frame trail. Ported from the legacy harness — winappcli has no -/// equivalent full-desktop capture, so this stays native (GDI). +/// Captures the full desktop (including the mouse cursor) to a PNG. Used by the pipeline path of +/// , which fires on a one-second timer so a +/// failed CI run carries a frame-by-frame trail, and by tests that need to read what the screen +/// actually shows. Ported from the legacy harness — winappcli has no equivalent full-desktop +/// capture, so this stays native (GDI). /// -internal static class ScreenCapture +public static class ScreenCapture { [DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hWnd); diff --git a/src/common/UITestAutomation.Next/SettingsConfigHelper.cs b/src/common/UITestAutomation.Next/SettingsConfigHelper.cs index 56272d8758..59685dea7e 100644 --- a/src/common/UITestAutomation.Next/SettingsConfigHelper.cs +++ b/src/common/UITestAutomation.Next/SettingsConfigHelper.cs @@ -77,6 +77,25 @@ public static class SettingsConfigHelper return new FileSnapshot(path); } + internal static IDisposable PreserveFirstRunSettings() => PreserveFirstRunSettings(PowerToysSettingsRoot); + + internal static IDisposable PreserveFirstRunSettings(string settingsRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(settingsRoot); + + var globalSettings = PreserveFile(Path.Combine(settingsRoot, "settings.json")); + try + { + var oobeSettings = PreserveFile(Path.Combine(settingsRoot, "oobe_settings.json")); + return new CompositeSnapshot(globalSettings, oobeSettings); + } + catch + { + globalSettings.Dispose(); + throw; + } + } + /// /// Enable exactly the named modules in the global settings.json and disable every other /// known or already-listed module. Module names are the keys under "enabled" @@ -231,4 +250,37 @@ public static class SettingsConfigHelper } } } + + private sealed class CompositeSnapshot(params IDisposable[] snapshots) : IDisposable + { + private bool disposed; + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + List? failures = null; + foreach (var snapshot in snapshots.Reverse()) + { + try + { + snapshot.Dispose(); + } + catch (Exception ex) + { + failures ??= []; + failures.Add(ex); + } + } + + if (failures is not null) + { + throw new AggregateException("One or more PowerToys settings files could not be restored.", failures); + } + } + } } diff --git a/src/common/UITestAutomation.Next/UITestBase.cs b/src/common/UITestAutomation.Next/UITestBase.cs index c2f420dbbf..d7a61d9651 100644 --- a/src/common/UITestAutomation.Next/UITestBase.cs +++ b/src/common/UITestAutomation.Next/UITestBase.cs @@ -43,6 +43,7 @@ public class UITestBase : IDisposable private SessionHelper? sessionHelper; private ScreenRecording? screenRecording; + private IDisposable? firstRunSettingsSnapshot; private string? recordingDirectory; private bool artifactsCaptured; private bool disposed; @@ -72,6 +73,14 @@ public class UITestBase : IDisposable /// protected virtual bool ReuseScopeAcrossTests => false; + /// + /// Prepare test-owned state after stale processes have stopped and immediately before the scope + /// launches. Override when constructor-created fixtures can be overwritten during process cleanup. + /// + protected virtual void PrepareTestState() + { + } + /// Module whose window the test drives. /// Optional fixed window size applied once the window appears. /// @@ -115,6 +124,7 @@ public class UITestBase : IDisposable DisplayHelper.LogMonitors(TestContext); } + firstRunSettingsSnapshot = SettingsConfigHelper.PreserveFirstRunSettings(); PreTestHygiene(); // Seed a deterministic module on/off baseline before the runner reads settings.json. @@ -122,6 +132,8 @@ public class UITestBase : IDisposable { SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules); } + + PrepareTestState(); } // Start the 1s screenshot timer + FFmpeg recording before the UI work so the artifacts @@ -150,31 +162,6 @@ public class UITestBase : IDisposable // media here (e.g. the window never appeared) before propagating — otherwise an init // failure would attach no diagnostics at all. await CaptureFailureArtifactsAsync(); - throw; - } - } - - [TestCleanup] - public async Task TestCleanup() - { - var failed = TestContext.CurrentTestOutcome is - UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown; - - if (failed) - { - await CaptureFailureArtifactsAsync(); - } - else if (isInPipeline) - { - // Passing test: stop the capture and discard the (now uninteresting) recording. - await StopPipelineCaptureAsync(); - CleanupRecordingDirectory(); - } - - // Tear the scope down only when each test owns its launch. With a class-shared scope the - // window must survive for the next test; the inherited ClassCleanup stops it at class end. - if (!ReuseScopeAcrossTests) - { try { sessionHelper?.StopIfStarted(); @@ -182,9 +169,56 @@ public class UITestBase : IDisposable catch { } - } - Dispose(); + try + { + Dispose(); + } + catch (Exception ex) + { + TestContext.WriteLine($"Failed to restore PowerToys settings after test initialization failed: {ex.Message}"); + } + + throw; + } + } + + [TestCleanup] + public async Task TestCleanup() + { + try + { + var failed = TestContext.CurrentTestOutcome is + UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown; + + if (failed) + { + await CaptureFailureArtifactsAsync(); + } + else if (isInPipeline) + { + // Passing test: stop the capture and discard the (now uninteresting) recording. + await StopPipelineCaptureAsync(); + CleanupRecordingDirectory(); + } + + // Tear the scope down only when each test owns its launch. With a class-shared scope the + // window must survive for the next test; the inherited ClassCleanup stops it at class end. + if (!ReuseScopeAcrossTests) + { + try + { + sessionHelper?.StopIfStarted(); + } + catch + { + } + } + } + finally + { + Dispose(); + } } /// @@ -519,7 +553,16 @@ public class UITestBase : IDisposable } disposed = true; - screenRecording?.Dispose(); - GC.SuppressFinalize(this); + try + { + var snapshot = firstRunSettingsSnapshot; + firstRunSettingsSnapshot = null; + snapshot?.Dispose(); + } + finally + { + screenRecording?.Dispose(); + GC.SuppressFinalize(this); + } } } diff --git a/src/common/UITestAutomation.Next/WindowControl.cs b/src/common/UITestAutomation.Next/WindowControl.cs index d99094efa5..2fa9778263 100644 --- a/src/common/UITestAutomation.Next/WindowControl.cs +++ b/src/common/UITestAutomation.Next/WindowControl.cs @@ -63,6 +63,12 @@ public static class WindowControl [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(POINT point); + + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr hWnd, uint gaFlags); + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach); @@ -82,6 +88,7 @@ public static class WindowControl private const uint WM_CLOSE = 0x0010; private const uint WM_CONTEXTMENU = 0x007B; + private const uint GaRoot = 2; private const int SW_RESTORE = 9; [StructLayout(LayoutKind.Sequential)] @@ -93,6 +100,13 @@ public static class WindowControl public int Bottom; } + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + [StructLayout(LayoutKind.Sequential)] private struct GUITHREADINFO { @@ -138,6 +152,60 @@ public static class WindowControl /// public static IReadOnlyList EnumerateAllWindows() => EnumerateTopLevelWindows(null); + /// + /// Whether any top-level window of is currently visible. + /// + /// + /// Cheap enough to poll: it reads class names only, never window titles, so it avoids the + /// cross-process WM_GETTEXT that performs per window. + /// For a window that only appears briefly, prefer — no poll can + /// tell "never shown" apart from "shown between two samples". + /// + public static bool IsAnyWindowOfClassVisible(string className) => + AnyWindowOfClass(className, requireVisible: true); + + /// Whether any top-level window of exists, visible or not. + public static bool AnyWindowOfClassExists(string className) => + AnyWindowOfClass(className, requireVisible: false); + + private static bool AnyWindowOfClass(string className, bool requireVisible) + { + // EnumWindows rather than chained FindWindowEx calls: when several windows share a class (the + // caller's product may pool and recycle them) the chained form is easy to get subtly wrong and + // end up only ever inspecting the first match. + var found = false; + + try + { + EnumWindows( + (hWnd, _) => + { + try + { + if ((!requireVisible || IsWindowVisible(hWnd)) && + GetWindowClassName(hWnd).Equals(className, StringComparison.OrdinalIgnoreCase)) + { + found = true; + return false; + } + } + catch + { + // Ignore any single window we can't read; keep enumerating. + } + + return true; + }, + IntPtr.Zero); + } + catch + { + // Best-effort: report whatever was determined before the failure. + } + + return found; + } + private static IReadOnlyList EnumerateTopLevelWindows(Func? pidFilter) { var result = new List(); @@ -344,6 +412,25 @@ public static class WindowControl /// Return the current foreground window handle. public static IntPtr GetForegroundWindowHandle() => GetForegroundWindow(); + /// Whether the root window under a screen point is the expected HWND. + public static bool IsPointOwnedByWindow(IntPtr window, int x, int y) + { + if (window == IntPtr.Zero) + { + return false; + } + + try + { + var atPoint = WindowFromPoint(new POINT { X = x, Y = y }); + return atPoint != IntPtr.Zero && GetAncestor(atPoint, GaRoot) == window; + } + catch + { + return false; + } + } + /// Open the context menu owned by the control that currently has focus in a foreground window. public static bool TryOpenContextMenuForFocusedControl(IntPtr ownerWindow) { diff --git a/src/common/UITestAutomation.Next/WindowHelper.cs b/src/common/UITestAutomation.Next/WindowHelper.cs index 088648e46a..2f1445d584 100644 --- a/src/common/UITestAutomation.Next/WindowHelper.cs +++ b/src/common/UITestAutomation.Next/WindowHelper.cs @@ -54,9 +54,13 @@ public static class WindowHelper private const uint SWP_NOACTIVATE = 0x0010; private const int GWL_EXSTYLE = -20; private const long WS_EX_TOPMOST = 0x00000008L; + private const long WS_EX_LAYERED = 0x00080000L; + private const uint LWA_ALPHA = 0x00000002; private const int SM_CXSCREEN = 0; private const int SM_CYSCREEN = 1; private const int SW_MAXIMIZE = 3; + private const int SW_RESTORE = 9; + private const int SW_MINIMIZE = 6; private const int DwmExtendedFrameBoundsAttribute = 9; [DllImport("user32.dll", SetLastError = true)] @@ -70,6 +74,13 @@ public static class WindowHelper [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); + [DllImport("user32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern IntPtr GetPropW(IntPtr hWnd, string lpString); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetLayeredWindowAttributes(IntPtr hWnd, out uint crKey, out byte bAlpha, out uint dwFlags); + [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); @@ -139,6 +150,17 @@ public static class WindowHelper /// public static void MaximizeWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_MAXIMIZE); + /// + /// Restore a window from maximized/minimized. Needed before positioning a window that a test will + /// then drag: SetWindowPos can move and size a maximized window without clearing its + /// maximized state, and dragging such a window makes Windows restore it mid-gesture instead of + /// performing a plain move. + /// + public static void RestoreWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_RESTORE); + + /// Minimize a window, e.g. to get it out of the way of an on-screen pixel measurement. + public static void MinimizeWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_MINIMIZE); + /// (Left, Top, Right, Bottom) of the window in screen pixels. public static (int Left, int Top, int Right, int Bottom) GetWindowBounds(IntPtr hWnd) { @@ -150,6 +172,13 @@ public static class WindowHelper return (0, 0, 0, 0); } + /// Read a named Win32 property stamped on a window, or zero when it is absent. + public static long GetWindowPropertyValue(IntPtr hWnd, string propertyName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + return GetPropW(hWnd, propertyName).ToInt64(); + } + /// /// 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. @@ -240,6 +269,23 @@ public static class WindowHelper return $"#{c.R:X2}{c.G:X2}{c.B:X2}"; } + /// + /// Alpha of a layered window, or 255 when it is not alpha-blended. Lets a test observe a module + /// that fades a window (FancyZones' "make the dragged window transparent") without sampling + /// pixels. + /// + public static byte GetWindowAlpha(IntPtr hWnd) + { + if ((GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64() & WS_EX_LAYERED) == 0) + { + return 255; + } + + return GetLayeredWindowAttributes(hWnd, out _, out var alpha, out var flags) && (flags & LWA_ALPHA) != 0 + ? alpha + : (byte)255; + } + private static (int Width, int Height) Dimensions(WindowSize size) => size switch { WindowSize.Small => (640, 480), diff --git a/src/common/UITestAutomation.Next/WindowShowWatcher.cs b/src/common/UITestAutomation.Next/WindowShowWatcher.cs new file mode 100644 index 0000000000..39fa6bedb1 --- /dev/null +++ b/src/common/UITestAutomation.Next/WindowShowWatcher.cs @@ -0,0 +1,169 @@ +// 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; + +namespace Microsoft.PowerToys.UITest.Next; + +/// +/// Records every time a top-level window of a given class is shown, using a WinEvent hook. +/// +/// +/// Use this instead of polling when the window +/// under observation is transient. Polling can only see a window that stays visible longer than the +/// sample interval, so it cannot distinguish "never shown" from "shown and hidden again immediately" — +/// and the interval cannot be lowered without the probe itself contending for the window manager. +/// EVENT_OBJECT_SHOW is delivered for every show regardless of how briefly the window survives. +/// +public sealed class WindowShowWatcher : IDisposable +{ + private const uint EVENT_OBJECT_SHOW = 0x8002; + private const uint EVENT_OBJECT_HIDE = 0x8003; + private const int OBJID_WINDOW = 0; + private const uint WINEVENT_OUTOFCONTEXT = 0x0000; + private const uint WINEVENT_SKIPOWNPROCESS = 0x0002; + private const uint PM_REMOVE = 1; + + private readonly string className; + private readonly ManualResetEventSlim shown = new(false); + private readonly ManualResetEventSlim ready = new(false); + private readonly Thread pump; + private readonly List events = new(); + private readonly Lock sync = new(); + private readonly WinEventProc callback; // keep the delegate alive for the hook's lifetime + + private volatile bool stop; + + public WindowShowWatcher(string className) + { + this.className = className; + callback = OnWinEvent; + + pump = new Thread(Pump) { IsBackground = true }; + pump.Start(); + ready.Wait(5_000); + } + + private delegate void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint idEventThread, uint dwmsEventTime); + + /// Show/hide events seen so far, for diagnostics. + public IReadOnlyList Events + { + get + { + lock (sync) + { + return events.ToArray(); + } + } + } + + /// Wait until a window of the watched class is shown. + public bool Wait(int timeoutMs) => shown.Wait(timeoutMs); + + public void Dispose() + { + stop = true; + pump.Join(2_000); + shown.Dispose(); + ready.Dispose(); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventProc lpfnWinEventProc, uint idProcess, uint idThread, uint dwFlags); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnhookWinEvent(IntPtr hWinEventHook); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PeekMessageW(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessageW(ref MSG lpMsg); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern int GetClassNameW(IntPtr hWnd, [Out] char[] lpClassName, int nMaxCount); + + private void OnWinEvent(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint idEventThread, uint dwmsEventTime) + { + if (idObject != OBJID_WINDOW || hwnd == IntPtr.Zero) + { + return; + } + + var buffer = new char[256]; + var length = GetClassNameW(hwnd, buffer, buffer.Length); + if (length <= 0 || !new string(buffer, 0, length).Equals(className, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // dwmsEventTime is when the OS raised the event; a timestamp taken here would instead measure + // how promptly this thread pumped its queue, which would understate a very short-lived window. + lock (sync) + { + events.Add($"{(eventType == EVENT_OBJECT_SHOW ? "SHOW" : "HIDE")} 0x{hwnd.ToInt64():X} @{dwmsEventTime}ms"); + } + + if (eventType == EVENT_OBJECT_SHOW) + { + shown.Set(); + } + } + + private void Pump() + { + var hook = SetWinEventHook( + EVENT_OBJECT_SHOW, + EVENT_OBJECT_HIDE, + IntPtr.Zero, + callback, + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + + ready.Set(); + if (hook == IntPtr.Zero) + { + return; + } + + try + { + while (!stop) + { + while (PeekMessageW(out var msg, IntPtr.Zero, 0, 0, PM_REMOVE)) + { + DispatchMessageW(ref msg); + } + + Thread.Sleep(5); + } + } + finally + { + UnhookWinEvent(hook); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG + { + public IntPtr Hwnd; + public uint Message; + public IntPtr WParam; + public IntPtr LParam; + public uint Time; + public POINT Point; + } +} diff --git a/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuide.Ui.csproj b/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuide.Ui.csproj index 13d599a25f..9eadf7d9a0 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuide.Ui.csproj +++ b/src/modules/ShortcutGuide/ShortcutGuide.Ui/ShortcutGuide.Ui.csproj @@ -50,7 +50,8 @@ - + + diff --git a/src/modules/fancyzones/FancyZones.UITests.Next/CoreBehaviorTests.cs b/src/modules/fancyzones/FancyZones.UITests.Next/CoreBehaviorTests.cs new file mode 100644 index 0000000000..205d0ea7ef --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests.Next/CoreBehaviorTests.cs @@ -0,0 +1,233 @@ +// 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 FancyZones.UITests.Utils; +using FancyZonesEditorCommon.Data; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static FancyZones.UITests.Utils.FancyZonesSettingsSeed; + +namespace FancyZones.UITests; + +/// +/// Focused backend workflows selected from the broader FancyZones manual checklist because they +/// have durable one-monitor signals and do not duplicate editor-only coverage. +/// +[TestClass] +public class CoreBehaviorTests : UITestBase +{ + private const long FirstZoneBitmask = 1L << 0; + private const long SecondZoneBitmask = 1L << 1; + + private readonly FancyZonesFiles files = new(); + + public CoreBehaviorTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.UnSpecified, [ModuleName]) + { + } + + protected override IReadOnlyList StaleProcessNames => FancyZonesTestHelper.StaleProcessNames; + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + FancyZonesTestHelper.CloseLayoutEditor(this); + FancyZonesTestHelper.CloseExplorerWindows(); + files.RestoreAll(); + } + + /// Excluded applications must never receive a FancyZones zone assignment. + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #Excluded apps")] + public void TestExcludedAppDoesNotSnap() + { + Arrange(seed => seed + .Set(Setting.ShiftDrag, true) + .Set(Setting.ExcludedApps, "explorer.exe")); + + var window = OpenExplorerForTest(); + var (targetX, targetY) = FancyZonesTestHelper.ScreenCenter(); + Assert.IsTrue( + FancyZonesTestHelper.BeginWindowDrag(this, window, targetX, targetY), + "Could not start the excluded Explorer window's title-bar drag."); + + using var overlayWatcher = new WindowShowWatcher(FancyZonesTestHelper.ZonesOverlayClassName); + KeyboardHelper.PressKey(Key.LShift); + overlayWatcher.Wait(2_000); + Assert.IsTrue( + FancyZonesTestHelper.WaitForZonesOverlayHidden(2_000, requiredConsecutiveMatches: 10), + "An excluded Explorer window should keep the zones overlay hidden."); + Assert.AreEqual( + 0, + overlayWatcher.Events.Count, + $"An excluded Explorer window triggered overlay events: {string.Join(", ", overlayWatcher.Events)}."); + + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + Thread.Sleep(1000); + + Assert.AreEqual( + 0L, + FancyZonesTestHelper.GetZoneBitmask(window), + "An excluded Explorer HWND must not be stamped with a zone assignment."); + + var history = files.AppZoneHistory.Exists ? files.AppZoneHistory.Read() : string.Empty; + Assert.IsNull( + ZoneHistory.GetZoneIndexSetByAppName("explorer.exe", history), + "An excluded Explorer window must not be written to app-zone-history.json."); + } + + /// + /// Covers one-monitor zone-index keyboard snapping without duplicating every arrow permutation: + /// native Windows Snap while override is off, FancyZones first/next/previous zones when on, and + /// moving a newly opened window to the final known zone. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #Override Windows Snap")] + [TestCategory("FancyZones #Move newly created windows to their last known zone")] + public void TestKeyboardSnapCycleAndRestoreLastZone() + { + Arrange(seed => seed + .Set(Setting.OverrideSnapHotkeys, false) + .Set(Setting.MoveWindowsBasedOnPosition, false) + .Set(Setting.MoveWindowAcrossMonitors, false) + .Set(Setting.AppLastZoneMoveWindows, true)); + + var folder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var window = OpenExplorerForTest(folder); + var beforeNativeSnap = WindowHelper.GetWindowBounds(window); + + SendWinArrow(window, Key.Right, "native Windows Snap while FancyZones override is disabled"); + var nativeSnap = WaitHelper.WaitForStable( + () => WindowHelper.GetWindowBounds(window), + bounds => bounds != beforeNativeSnap, + 5_000, + requiredConsecutiveMatches: 2, + pollIntervalMS: 100); + Assert.IsTrue( + nativeSnap.Succeeded, + $"Win+Right did not change Explorer HWND {window} geometry while Override Windows Snap was disabled. " + + $"Before: {beforeNativeSnap}; after: {nativeSnap.LastObservation}."); + Assert.AreEqual( + 0L, + FancyZonesTestHelper.GetZoneBitmask(window), + "Disabling Override Windows Snap should leave the HWND without a FancyZones zone stamp."); + + Assert.IsTrue( + WindowControl.TryCloseByApp( + "explorer", + candidate => candidate.Hwnd == window, + 10_000), + $"Could not close the native-snapped Explorer HWND {window} before the enabled phase."); + + new FancyZonesSettingsSeed() + .Set(Setting.OverrideSnapHotkeys, true) + .Apply(); + FancyZonesTestHelper.Step(this, "Restarting PowerToys with Override Windows Snap enabled"); + FancyZonesTestHelper.RestartPowerToys(this); + FancyZonesTestHelper.EnsureFancyZonesRunning(this); + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + window = OpenExplorerForTest(folder); + + SendWinArrowAndAssertZone(window, Key.Right, FirstZoneBitmask); + SendWinArrowAndAssertZone(window, Key.Right, SecondZoneBitmask); + SendWinArrowAndAssertZone(window, Key.Left, FirstZoneBitmask); + + Assert.IsTrue( + WindowControl.TryCloseByApp( + "explorer", + candidate => candidate.Hwnd == window, + 10_000), + $"Could not close the zoned Explorer HWND {window} before testing last-zone restore."); + + var reopened = FancyZonesTestHelper.OpenExplorerWindow(this, folder); + Assert.AreNotEqual( + window, + reopened, + "The last-zone restore assertion requires a newly created Explorer HWND."); + Assert.IsTrue( + FancyZonesTestHelper.WaitForZoneBitmask(reopened, FirstZoneBitmask, 10_000), + $"The reopened Explorer HWND {reopened} did not return to zone 0. " + + $"Observed bitmask: 0x{FancyZonesTestHelper.GetZoneBitmask(reopened):X}."); + } + + private void Arrange(Action configure) + { + files.AppZoneHistory.Delete(); + files.AppliedLayouts.Delete(); + files.CustomLayouts.Write(new CustomLayouts().Serialize(LayoutFixtures.QuickSwitchCustomLayouts)); + files.LayoutHotkeys.Write(new LayoutHotkeys().Serialize(LayoutFixtures.QuickSwitchHotkeys)); + + var seed = new FancyZonesSettingsSeed() + .Set(Setting.QuickLayoutSwitch, true) + .Set(Setting.FlashZonesOnQuickSwitch, false) + .Set(Setting.ShiftDrag, true) + .Set(Setting.MouseSwitch, false) + .Set(Setting.MakeDraggedWindowTransparent, false) + .Set(Setting.ShowZoneNumber, false) + .Set(Setting.ExcludedApps, string.Empty) + .Set(Setting.OverrideSnapHotkeys, false) + .Set(Setting.MoveWindowsBasedOnPosition, false) + .Set(Setting.MoveWindowAcrossMonitors, false) + .Set(Setting.AppLastZoneMoveWindows, false); + configure(seed); + seed.Apply(); + + FancyZonesTestHelper.Step(this, "Restarting PowerToys for the focused backend scenario"); + FancyZonesTestHelper.RestartPowerToys(this); + FancyZonesTestHelper.EnsureFancyZonesRunning(this); + + FancyZonesTestHelper.Step(this, "Applying the 2x2 custom layout with Win+Ctrl+Alt+0"); + KeyboardHelper.SendKeys(Key.LWin, Key.Ctrl, Key.Alt, Key.Num0); + Assert.IsTrue( + FancyZonesTestHelper.AppliedLayoutContains(LayoutFixtures.GridCustomLayoutUuid, 15_000), + $"Could not apply setup layout {LayoutFixtures.GridCustomLayoutUuid}. " + + $"Last content: {FancyZonesTestHelper.ReadAppliedLayouts()}"); + + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + } + + private IntPtr OpenExplorerForTest(string? folder = null) + { + var window = FancyZonesTestHelper.OpenExplorerWindow(this, folder); + ResetWindowForKeyboardSnap(window); + return window; + } + + private static void ResetWindowForKeyboardSnap(IntPtr window) + { + WindowHelper.RestoreWindow(window); + WindowHelper.SetWindowSize(window, WindowSize.Medium); + WindowControl.TryBringToForeground(window); + Assert.IsTrue( + WindowControl.WaitForForeground(window, 5_000, 2), + $"Explorer HWND {window} did not take foreground for keyboard snapping."); + Thread.Sleep(300); + } + + private void SendWinArrowAndAssertZone(IntPtr window, Key arrow, long expectedBitmask) + { + SendWinArrow(window, arrow, $"FancyZones zone bitmask 0x{expectedBitmask:X}"); + Assert.IsTrue( + FancyZonesTestHelper.WaitForZoneBitmask(window, expectedBitmask, 5_000), + $"Win+{arrow} did not move Explorer HWND {window} to bitmask 0x{expectedBitmask:X}. " + + $"Observed: 0x{FancyZonesTestHelper.GetZoneBitmask(window):X}."); + } + + private void SendWinArrow(IntPtr window, Key arrow, string purpose) + { + WindowControl.TryBringToForeground(window); + Assert.IsTrue( + WindowControl.WaitForForeground(window, 5_000, 2), + $"Explorer HWND {window} was not foreground before {purpose}."); + FancyZonesTestHelper.Step(this, $"Sending Win+{arrow} for {purpose}"); + KeyboardHelper.SendKeys(Key.LWin, arrow); + } +} diff --git a/src/modules/fancyzones/FancyZones.UITests.Next/DragWindowTests.cs b/src/modules/fancyzones/FancyZones.UITests.Next/DragWindowTests.cs new file mode 100644 index 0000000000..f8235312a5 --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests.Next/DragWindowTests.cs @@ -0,0 +1,501 @@ +// 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.Windows.Forms; +using FancyZones.UITests.Utils; +using FancyZonesEditorCommon.Data; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static FancyZones.UITests.Utils.FancyZonesSettingsSeed; + +namespace FancyZones.UITests; + +/// +/// Port of the legacy DragWindowTests: the zone-behaviour matrix — Shift key, non-primary +/// mouse button, and dragged-window transparency — exercised by dragging a window across a seeded +/// single-zone layout. +/// +/// +/// +/// Two deliberate departures from the legacy suite, both forced by what is actually observable: +/// +/// +/// The subject window is File Explorer, not PowerToys Settings. Synthetic +/// title-bar drags of the Settings window did not start a move loop FancyZones could see, so the +/// window moved without any zones appearing. Explorer is a plain top-level window that responds to +/// an injected drag the same way it does to a real one. (This is about driving the drag from a test, +/// not about the window's UI framework - dragging Settings or Notepad by hand activates zones +/// normally.) +/// Zone activation is asserted through the snap outcome, not the zone +/// colour. The legacy tests sampled the highlight colour off the screen, but FancyZones paints +/// zones on a layered, DWM-composited overlay that GDI screen reads (both GetPixel and +/// CopyFromScreen) do not include — a probe there returns the wallpaper whether or not zones +/// are drawn. Whether the drop snaps the window is the same behaviour seen from the outside, and it +/// is durable: FancyZones records it in app-zone-history.json. +/// +/// +/// The zone-behaviour options themselves are seeded into the module's settings.json instead of +/// being clicked through the Settings page, which is both deterministic and locale independent. +/// +/// +[TestClass] +public class DragWindowTests : UITestBase +{ + /// Executable recorded in app-zone-history.json for the dragged window. + private const string DraggedApp = "explorer.exe"; + + /// Alpha FancyZones applies while a dragged window is made transparent (50%). + private const byte TransparentAlpha = 127; + + private readonly FancyZonesFiles files = new(); + + private IntPtr draggedWindow; + + public DragWindowTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.UnSpecified, [ModuleName]) + { + } + + protected override IReadOnlyList StaleProcessNames => FancyZonesTestHelper.StaleProcessNames; + + /// The button that toggles zone activation, honouring a swapped-buttons mouse. + private static bool NonPrimaryIsRight => !SystemInformation.MouseButtonsSwapped; + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + + // A test that failed mid-gesture can leave the button/modifier down; free them first. + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + + FancyZonesTestHelper.CloseLayoutEditor(this); + FancyZonesTestHelper.CloseExplorerWindows(); + files.RestoreAll(); + } + + /// + /// Test Use Shift key to activate zones while dragging a window in FancyZones Zone Behaviour Settings. + /// Verifies that holding Shift while dragging activates the zones, so the drop snaps the window. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #1")] + public void TestShowZonesOnShiftDuringDrag() + { + Arrange(shiftDrag: true, mouseSwitch: false, transparent: false); + + StartShiftActivatedDrag(); + DropAndAssertSnapped("Holding Shift during the drag should activate the zones."); + } + + /// + /// Test dragging a window while the Shift key is already held. + /// Verifies that starting the drag with Shift down activates the zones. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #2")] + public void TestShowZonesOnDragDuringShift() + { + Arrange(shiftDrag: true, mouseSwitch: false, transparent: false); + + KeyboardHelper.PressKey(Key.LShift); + Thread.Sleep(200); + Assert.IsTrue(StartDrag(), "Could not start a title-bar drag while Shift was held."); + DropAndAssertSnapped("Starting the drag with Shift already held should activate the zones."); + } + + /// + /// Test toggling zones using a non-primary mouse click during window dragging. + /// Verifies that clicking a non-primary mouse button deactivates zones while dragging a window. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #3")] + public void TestToggleZonesWithNonPrimaryMouseClick() + { + Arrange(shiftDrag: false, mouseSwitch: true, transparent: false); + + Assert.IsTrue(StartDrag(), "Could not start the title-bar drag."); + Assert.IsTrue( + FancyZonesTestHelper.WaitForZonesOverlayVisible(), + "The drag never activated the zones overlay, so the non-primary click had no active state to toggle."); + ClickNonPrimaryButton(); + Assert.IsTrue( + FancyZonesTestHelper.WaitForZonesOverlayHidden(), + "The zones overlay remained visible after the non-primary mouse click."); + Drop(); + + AssertSnapped(false, "A non-primary mouse click during the drag should deactivate the zones."); + } + + /// + /// Test both "use Shift" and "non-primary mouse" settings off. + /// Verifies that zones are active as soon as the drag starts, and that holding Shift deactivates + /// them again. + /// + /// + /// + /// Asserted through the dragged window's alpha rather than the snap outcome: FancyZones fades the + /// window from SwitchSnappingMode(true) and clears the fade in the same false branch + /// that hides the zones, so the alpha tracks exactly the zones-active state the legacy test sampled + /// as a colour - and unlike the snap, it can be read at both points of the same drag. + /// + /// + /// The Shift-before-the-drag case is the control for the mid-drag one: it drives the same setting + /// down the same state machine, differing only in whether FancyZones was already showing zones when + /// the key went down. + /// + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #4")] + public void TestShowZonesWhenShiftAndMouseOff() + { + Arrange(shiftDrag: false, mouseSwitch: false, transparent: true); + + Assert.IsTrue(StartDrag(), "Could not start the title-bar drag."); + var whileDragging = WaitForWindowAlpha(TransparentAlpha); + FancyZonesTestHelper.Step(this, $"Alpha at drag start: {whileDragging}"); + + // Positive control for the overlay detector: at alpha 127 the zones are provably on screen, so + // this is the one moment a visible-overlay probe must succeed. + FancyZonesTestHelper.Step( + this, + $"Zones overlay reported visible while zones are active: {FancyZonesTestHelper.IsZonesOverlayVisible()}"); + + PressShiftDuringDrag(); + var shiftReachedTheSystem = KeyboardHelper.IsKeyDown(Key.Shift); + var afterShift = WaitForWindowAlpha(255); + FancyZonesTestHelper.Step( + this, + $"Alpha after pressing Shift mid-drag: {afterShift} (system reports Shift held: {shiftReachedTheSystem})"); + + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + Thread.Sleep(1000); + + var alphaWhenShiftHeldFirst = DragWithShiftHeldFromTheStart(); + FancyZonesTestHelper.Step(this, $"Alpha when Shift was held before the drag: {alphaWhenShiftHeldFirst}"); + + Assert.AreEqual(TransparentAlpha, whileDragging, "Zones should be active as soon as the drag starts."); + Assert.AreEqual( + (byte)255, + alphaWhenShiftHeldFirst, + "With Shift-to-activate off, a drag started while Shift is held should leave the zones inactive."); + Assert.AreEqual( + (byte)255, + afterShift, + "With Shift-to-activate off, holding Shift should deactivate the zones. This regressed once " + + "before: FancyZones' low-level hook swallows the bare Shift while zones are showing, which " + + "also hid it from the module's own raw-input handler, so OnKeyDown must record the press " + + $"itself. (The system still reports Shift held = {shiftReachedTheSystem}, because the key is " + + "deliberately kept from the foreground app.)"); + } + + /// + /// Test zone visibility when both the Shift key and non-primary mouse settings are on. + /// Verifies that Shift activates the zones during a drag and a non-primary mouse click then + /// deactivates them again. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #5")] + public void TestShowZonesWhenShiftAndMouseOn() + { + Arrange(shiftDrag: true, mouseSwitch: true, transparent: false); + + StartShiftActivatedDrag(); + ClickNonPrimaryButton(); + Drop(); + KeyboardHelper.ReleaseKey(Key.LShift); + + AssertSnapped(false, "The non-primary mouse click should deactivate the zones Shift had activated."); + } + + /// + /// Test that a window becomes transparent during dragging when the transparent window setting is + /// enabled. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #8")] + public void TestMakeDraggedWindowTransparentOn() + { + Arrange(shiftDrag: true, mouseSwitch: false, transparent: true); + + Assert.AreEqual( + TransparentAlpha, + DragAndReadWindowAlpha(), + $"The dragged window should be faded to alpha {TransparentAlpha} while the zones are active."); + } + + /// + /// Test that a window remains opaque during dragging when the transparent window setting is + /// disabled. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones_Dragging #8")] + public void TestMakeDraggedWindowTransparentOff() + { + Arrange(shiftDrag: true, mouseSwitch: false, transparent: false, twoZones: true); + + Assert.AreEqual( + (byte)255, + DragAndReadWindowAlpha(), + "The dragged window should stay opaque while the transparency setting is off."); + } + + /// + /// Seed the layout and zone-behaviour settings for one scenario, relaunch PowerToys so the module + /// reads them, apply the seeded layout through the editor, and open the window that will be + /// dragged. + /// + private void Arrange(bool shiftDrag, bool mouseSwitch, bool transparent, bool twoZones = false) + { + FancyZonesTestHelper.Step(this, $"Seeding layout ({(twoZones ? "two zones" : "one zone")}) and zone-behaviour settings"); + + files.AppZoneHistory.Delete(); + files.AppliedLayouts.Delete(); + files.CustomLayouts.Write(new CustomLayouts().Serialize( + twoZones ? LayoutFixtures.TwoZoneColumns : LayoutFixtures.SingleZoneColumn)); + + new FancyZonesSettingsSeed() + .Set(Setting.ShiftDrag, shiftDrag) + .Set(Setting.MouseSwitch, mouseSwitch) + .Set(Setting.MakeDraggedWindowTransparent, transparent) + .Set(Setting.ShowZoneNumber, false) + .Set(Setting.SystemTheme, false) + .Set(Setting.HighlightOpacity, 100) + .Set(Setting.AllowChildWindowSnap, true) + .Set(Setting.AllowPopupWindowSnap, true) + .Apply(); + + // The settings themselves are hot-reloaded and would not need this. The restart is here for + // the LAYOUT EDITOR: FancyZones' ToggleEditor treats the toggle event as "close" while its + // terminate-editor handle is alive, and that state survives between tests, so a long-lived + // module ends up swallowing the next open. Measured: 14/17 with the restart, 3/17 without. + FancyZonesTestHelper.Step(this, "Restarting PowerToys to reset the FancyZones editor toggle state"); + FancyZonesTestHelper.RestartPowerToys(this); + + FancyZonesTestHelper.EnsureFancyZonesRunning(this); + FancyZonesTestHelper.ApplyLayoutThroughEditor( + this, + By.Name(FancyZonesTestHelper.LayoutName.CustomColumn), + LayoutFixtures.CustomColumnUuid); + + // The Settings window is only here because it owns the runner; keep it off the drag surface. + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + Thread.Sleep(500); + + draggedWindow = FancyZonesTestHelper.OpenExplorerWindow(this); + WindowControl.TryBringToForeground(draggedWindow); + + // Restore first: SetWindowPos resizes a maximized window without clearing its maximized + // state, and dragging it would then make Windows restore it mid-gesture instead of moving it. + WindowHelper.RestoreWindow(draggedWindow); + Thread.Sleep(300); + WindowHelper.SetWindowSize(draggedWindow, WindowSize.Medium); + Thread.Sleep(500); + + // app-zone-history is the assertion signal, so it must start empty even if opening the window + // restored it to a previously remembered zone. + files.AppZoneHistory.Delete(); + + FancyZonesTestHelper.Step(this, $"Window to drag ready at {WindowHelper.GetWindowBounds(draggedWindow)}"); + } + + /// Grab the window by its title bar and drag it towards the centre, button still down. + private bool StartDrag() + { + var (centerX, centerY) = FancyZonesTestHelper.ScreenCenter(); + return FancyZonesTestHelper.BeginWindowDrag(this, draggedWindow, centerX, centerY); + } + + /// Release the drag and let FancyZones settle the snap. + private void Drop() + { + MouseHelper.LeftUp(); + Thread.Sleep(1500); + } + + /// Release the mouse but keep Shift held until FancyZones records MoveSizeEnd. + private void DropAndAssertSnapped(string because) + { + MouseHelper.LeftUp(); + try + { + AssertSnapped(true, because); + } + finally + { + KeyboardHelper.ReleaseKey(Key.LShift); + } + } + + /// Hold Shift mid-drag; the product posts its own location update for the new key state. + private void PressShiftDuringDrag() + { + FancyZonesTestHelper.Step(this, "Pressing Shift during the drag"); + KeyboardHelper.PressKey(Key.LShift); + Thread.Sleep(300); + } + + /// Retry the complete grab-and-activate gesture on the same Explorer HWND. + private void StartShiftActivatedDrag() + { + const int attempts = 3; + + for (var attempt = 1; attempt <= attempts; attempt++) + { + FancyZonesTestHelper.Step(this, $"Shift-activated drag attempt {attempt}/{attempts}"); + if (!FancyZonesTestHelper.WaitForZonesOverlayHidden()) + { + FancyZonesTestHelper.Step(this, "The previous overlay did not hide; resetting before the next attempt"); + ResetDraggedWindowForRetry(); + continue; + } + + if (!StartDrag()) + { + FancyZonesTestHelper.Step(this, "Could not acquire the title bar; resetting the same window before retrying"); + ResetDraggedWindowForRetry(); + continue; + } + + if (FancyZonesTestHelper.ActivateZonesWithShiftDuringDrag(this)) + { + return; + } + + FancyZonesTestHelper.Step(this, "The overlay did not stabilize; releasing input and regrabbing the same window"); + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + FancyZonesTestHelper.WaitForZonesOverlayHidden(); + ResetDraggedWindowForRetry(); + } + + Assert.Fail($"Holding Shift during the drag never made the zones overlay stable after {attempts} complete drag attempts."); + } + + private void ResetDraggedWindowForRetry() + { + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + WindowControl.TryBringToForeground(draggedWindow); + WindowHelper.RestoreWindow(draggedWindow); + WindowHelper.SetWindowSize(draggedWindow, WindowSize.Medium); + Thread.Sleep(750); + } + + /// + /// Park the window back in the top-left quadrant, hold Shift, and only then start a drag. Reports + /// the dragged window's alpha, which is 255 while the zones are inactive. + /// + private byte DragWithShiftHeldFromTheStart() + { + WindowHelper.MoveWindow(draggedWindow, 100, 100); + Thread.Sleep(500); + + FancyZonesTestHelper.Step(this, "Holding Shift before the drag starts"); + KeyboardHelper.PressKey(Key.LShift); + Thread.Sleep(500); + + Assert.IsTrue(StartDrag(), "Could not start the title-bar drag while Shift was held."); + Thread.Sleep(1000); + var alpha = WindowHelper.GetWindowAlpha(draggedWindow); + + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + Thread.Sleep(500); + return alpha; + } + + private void ClickNonPrimaryButton() + { + FancyZonesTestHelper.Step(this, $"Clicking the non-primary mouse button ({(NonPrimaryIsRight ? "right" : "left")})"); + if (NonPrimaryIsRight) + { + MouseHelper.RightClick(); + } + else + { + MouseHelper.LeftClick(); + } + + Thread.Sleep(800); + } + + /// Poll the dragged window's alpha until it reaches . + private byte WaitForWindowAlpha(byte expected, int timeoutMs = 5_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + byte alpha; + do + { + alpha = WindowHelper.GetWindowAlpha(draggedWindow); + if (alpha == expected) + { + break; + } + + Thread.Sleep(200); + } + while (DateTime.UtcNow < deadline); + + return alpha; + } + + /// Drag with the zones active and read the dragged window's alpha mid-gesture. + private byte DragAndReadWindowAlpha() + { + KeyboardHelper.PressKey(Key.LShift); + Thread.Sleep(200); + Assert.IsTrue(StartDrag(), "Could not start the title-bar drag while Shift was held."); + + // The fade is applied when the zones engage, which can trail the first move on a slow machine. + var alpha = WaitForWindowAlpha(TransparentAlpha); + FancyZonesTestHelper.Step(this, $"Dragged window alpha while dragging: {alpha}"); + + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + Thread.Sleep(500); + return alpha; + } + + /// + /// Assert whether the drop snapped the window, read from app-zone-history.json — the record + /// FancyZones writes when a window is assigned to a zone. + /// + private void AssertSnapped(bool expected, string because) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + string? zoneIndex; + do + { + zoneIndex = ZoneHistory.GetZoneIndexSetByAppName( + DraggedApp, + files.AppZoneHistory.Exists ? files.AppZoneHistory.Read() : string.Empty); + + if ((zoneIndex is not null) == expected) + { + break; + } + + Thread.Sleep(500); + } + while (DateTime.UtcNow < deadline); + + FancyZonesTestHelper.Step(this, $"app-zone-history zone index for {DraggedApp}: {zoneIndex ?? ""}"); + + var observed = zoneIndex is null ? "no entry" : $"zone {zoneIndex}"; + Assert.AreEqual( + expected, + zoneIndex is not null, + $"{because} Expected the window {(expected ? "to snap into a zone" : "not to snap")}, but app-zone-history reported {observed}."); + } +} diff --git a/src/modules/fancyzones/FancyZones.UITests.Next/FancyZones.UITests.Next.csproj b/src/modules/fancyzones/FancyZones.UITests.Next/FancyZones.UITests.Next.csproj new file mode 100644 index 0000000000..c642829256 --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests.Next/FancyZones.UITests.Next.csproj @@ -0,0 +1,45 @@ + + + + + + Exe + net10.0-windows10.0.26100.0 + enable + enable + false + false + FancyZones.UITests + FancyZones.UITests.Next + + + app.manifest + + + true + true + false + + + false + + + + + $(RepoRoot)$(Platform)\$(Configuration)\tests\FancyZones.UITests.Next\ + + + + + + + + + + + + diff --git a/src/modules/fancyzones/FancyZones.UITests.Next/LayoutApplyHotKeyTests.cs b/src/modules/fancyzones/FancyZones.UITests.Next/LayoutApplyHotKeyTests.cs new file mode 100644 index 0000000000..834c6fe771 --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests.Next/LayoutApplyHotKeyTests.cs @@ -0,0 +1,461 @@ +// 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 FancyZones.UITests.Utils; +using FancyZonesEditorCommon.Data; +using Microsoft.PowerToys.UITest.Next; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static FancyZones.UITests.Utils.FancyZonesSettingsSeed; +using Id = FancyZones.UITests.Utils.FancyZonesTestHelper.AccessibilityId; + +namespace FancyZones.UITests; + +/// +/// Port of the legacy LayoutApplyHotKeyTests: quick layout switching (Win+Ctrl+Alt+digit), +/// zone flashing, virtual-desktop layout persistence, custom-layout deletion and the editor's +/// reaction to a monitor change. +/// +[TestClass] +public class LayoutApplyHotKeyTests : UITestBase +{ + private const string SaveButtonName = "Save"; + private const string NewLayoutName = "Custom layout 1"; + + private readonly FancyZonesFiles files = new(); + + public LayoutApplyHotKeyTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.UnSpecified, [ModuleName]) + { + } + + protected override IReadOnlyList StaleProcessNames => FancyZonesTestHelper.StaleProcessNames; + + [TestCleanup] + public async Task CleanupTest() + { + await CaptureFailureArtifactsBeforeCleanupAsync(); + + MouseHelper.LeftUp(); + KeyboardHelper.ReleaseKey(Key.LShift); + FancyZonesTestHelper.CloseLayoutEditor(this); + FancyZonesTestHelper.CloseExplorerWindows(); + files.RestoreAll(); + } + + /// + /// Verifies that each quick-switch hotkey applies the custom layout it is bound to. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #1")] + public void TestApplyHotKey() + { + Arrange(quickLayoutSwitch: true); + AssignQuickKeyThroughEditor(Id.GridCustomLayoutCard, "0"); + + AssertLayoutAfterHotkey(Key.Num0, Id.GridCustomLayoutCard, expectSelected: true); + AssertLayoutAfterHotkey(Key.Num1, Id.Grid9LayoutCard, expectSelected: true); + AssertLayoutAfterHotkey(Key.Num2, Id.CanvasCustomLayoutCard, expectSelected: true); + } + + /// + /// Verifies that the quick-layout chord applies its layout while a window move loop is active. + /// The checklist's historical "digit only" wording is obsolete; current FancyZones deliberately + /// requires Win+Ctrl+Alt while dragging to avoid stealing number keys from applications. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #2")] + public void TestQuickLayoutHotKeyDuringDrag() + { + Arrange(quickLayoutSwitch: true, shiftDrag: false); + + FancyZonesTestHelper.Step(this, "Applying Grid-9 as setup with Win+Ctrl+Alt+1"); + KeyboardHelper.SendKeys(Key.LWin, Key.Ctrl, Key.Alt, Key.Num1); + Assert.IsTrue( + FancyZonesTestHelper.AppliedLayoutContains(LayoutFixtures.Grid9LayoutUuid, 15_000), + $"Could not apply the setup layout {LayoutFixtures.Grid9LayoutUuid}. " + + $"Last content: {FancyZonesTestHelper.ReadAppliedLayouts()}"); + + WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle)); + var window = FancyZonesTestHelper.OpenExplorerWindow(this); + WindowHelper.RestoreWindow(window); + WindowHelper.SetWindowSize(window, WindowSize.Medium); + Thread.Sleep(500); + + var (targetX, targetY) = FancyZonesTestHelper.ScreenCenter(); + Assert.IsTrue( + FancyZonesTestHelper.BeginWindowDrag(this, window, targetX, targetY), + "Could not start the Explorer title-bar drag needed to test quick layout switching."); + + try + { + Assert.IsTrue( + FancyZonesTestHelper.WaitForZonesOverlayVisible(), + "The drag move loop never activated the zones overlay."); + + FancyZonesTestHelper.Step(this, "Sending Win+Ctrl+Alt+0 while the drag is active"); + KeyboardHelper.SendKeys(Key.LWin, Key.Ctrl, Key.Alt, Key.Num0); + + Assert.IsTrue( + FancyZonesTestHelper.AppliedLayoutContains(LayoutFixtures.GridCustomLayoutUuid, 15_000), + $"The drag-specific quick-layout chord did not apply {LayoutFixtures.GridCustomLayoutUuid}. " + + $"Last content: {FancyZonesTestHelper.ReadAppliedLayouts()}"); + } + finally + { + MouseHelper.LeftUp(); + } + } + + /// + /// Verifies that switching layout with the hotkey flashes the zones when that option is on. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #3")] + public void HotKeyWindowFlashTest() + { + Arrange(quickLayoutSwitch: true, flashZones: true); + + // Zones flash when the layout CHANGES, so each attempt switches to a different layout — + // re-sending the same chord re-applies the layout already in effect and flashes nothing. The + // list cycles so that whichever layout happens to be applied first, a real switch follows. + Key[] chords = [Key.Num0, Key.Num1, Key.Num2, Key.Num0, Key.Num1]; + var flashed = false; + for (var attempt = 0; attempt < chords.Length && !flashed; attempt++) + { + var chord = chords[attempt]; + var before = FancyZonesTestHelper.ReadAppliedLayouts(); + FancyZonesTestHelper.Step(this, $"Sending Win+Ctrl+Alt+{chord} and watching for the zones overlay"); + flashed = FancyZonesTestHelper.DidZonesFlash( + this, + () => KeyboardHelper.SendKeys(Key.LWin, Key.Ctrl, Key.Alt, chord), + 5_000); + + var after = FancyZonesTestHelper.ReadAppliedLayouts(); + FancyZonesTestHelper.Step( + this, + $"{chord}: flashed={flashed}, layout changed={!string.Equals(before, after, StringComparison.Ordinal)}"); + } + + Assert.IsTrue( + flashed, + $"No visible '{FancyZonesTestHelper.ZonesOverlayClassName}' window appeared, so the zones did not flash on the layout switch."); + } + + /// + /// Verifies that the quick-switch hotkeys do nothing while quick layout switching is disabled. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #4")] + public void TestDisableApplyHotKey() + { + Arrange(quickLayoutSwitch: false); + + AssertLayoutAfterHotkey(Key.Num0, Id.GridCustomLayoutCard, expectSelected: false); + AssertLayoutAfterHotkey(Key.Num1, Id.Grid9LayoutCard, expectSelected: false); + AssertLayoutAfterHotkey(Key.Num2, Id.CanvasCustomLayoutCard, expectSelected: false); + } + + /// + /// Verifies that a layout applied on one virtual desktop is still applied after a new desktop is + /// created and PowerToys restarts. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #6")] + public void TestVirtualDesktopLayout() + { + Arrange(quickLayoutSwitch: true); + SelectLayoutInEditor(Id.GridCustomLayoutCard); + + try + { + FancyZonesTestHelper.Step(this, "Creating a virtual desktop and restarting PowerToys"); + KeyboardHelper.SendKeys(Key.Ctrl, Key.LWin, Key.D); + Thread.Sleep(1000); + + FancyZonesTestHelper.RestartPowerToys(this); + FancyZonesTestHelper.EnsureFancyZonesRunning(this); + + AssertLayoutSelected(Id.GridCustomLayoutCard, expectSelected: true); + } + finally + { + CloseExtraVirtualDesktop(); + } + } + + /// + /// Verifies that each virtual desktop keeps its own layout: selecting a different layout on a new + /// desktop must not change the layout of the original one. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #7")] + public void TestVirtualDesktopLayoutExt() + { + Arrange(quickLayoutSwitch: true); + SelectLayoutInEditor(Id.GridCustomLayoutCard); + + try + { + FancyZonesTestHelper.Step(this, "Creating a second virtual desktop and applying a different layout there"); + KeyboardHelper.SendKeys(Key.Ctrl, Key.LWin, Key.D); + Thread.Sleep(1000); + + FancyZonesTestHelper.RestartPowerToys(this); + FancyZonesTestHelper.EnsureFancyZonesRunning(this); + SelectLayoutInEditor(Id.Grid9LayoutCard); + + FancyZonesTestHelper.Step(this, "Returning to the first virtual desktop"); + KeyboardHelper.SendKeys(Key.Ctrl, Key.LWin, Key.Left); + Thread.Sleep(1000); + + FancyZonesTestHelper.RestartPowerToys(this); + FancyZonesTestHelper.EnsureFancyZonesRunning(this); + + AssertLayoutSelected(Id.GridCustomLayoutCard, expectSelected: true); + } + finally + { + CloseExtraVirtualDesktop(); + } + } + + /// + /// Verifies that deleting the applied custom layout falls back to the empty ("No layout") template. + /// + [TestMethod] + [TestCategory("FancyZones")] + [TestCategory("FancyZones #8")] + public void TestDeleteCustomLayoutBehavior() + { + Arrange(quickLayoutSwitch: true); + + var editor = FancyZonesTestHelper.OpenLayoutEditor(this); + try + { + FancyZonesTestHelper.ApplyLayout(this, editor, By.AccessibilityId(Id.GridCustomLayoutCard)); + + FancyZonesTestHelper.Step(this, "Deleting the applied custom layout"); + FancyZonesTestHelper.OpenEditLayoutDialog( + this, + editor, + Id.GridCustomLayoutCard, + By.AccessibilityId(Id.DeleteLayoutButton)); + + editor.Find