> Testing a desktop app on a disposable Windows VM — from The Handover, the-handover.org/docs/windows-vm-testing-for-desktop-apps > Authors: Leon Mallett (captivated.online) with Claude Code · Last confirmed working: 2026-08-11 > © Captivated Ltd — free to use in your own work, not to redistribute as a collection. the-handover.org/licence Spinning up an isolated Windows VM to test what CI cannot: installers, shell and wallpaper changes, tray applications, UI, global hotkeys. Cloud-flavoured toward Azure, but the interactive-session and permissions problems are Windows problems and travel anywhere. ## Hygiene, before anything is created On a shared tenancy, these are not suggestions — they are what stops one project's testing from destroying another's. 1. **One resource group per project**, named after the project. VM, NIC, disk, IP and network security group all go in it. That group is the isolation boundary: a project's whole test environment lives and dies as a unit. Never test inside another project's group. 2. **Check before you create.** If the group already exists, start its VM rather than creating a second one. 3. **Deallocate after every batch.** A *running* VM bills per hour even when idle; deallocating stops compute charges and leaves only trivial disk cost. Do it the moment a round of testing ends, not "just in case". 4. **Delete when the project is finished.** Deleting the resource group removes everything cleanly. Deallocate is pausing; delete is gone. 5. **Only ever mutate your own group.** If another project has left something running, report it rather than touching it. 6. **Never commit VM passwords or private keys.** They belong in an ephemeral scratch directory, not in a repository. ## Provisioning Namespace everything to a project slug, and put keys and screenshots in a scratch directory outside any repository. ```bash az group create -n "$RG" -l "$LOC" az vm create -g "$RG" -n "$VM" \ --image MicrosoftWindowsDesktop:windows-11:win11-24h2-pro:latest \ --size \ --admin-username "$ADMIN" --admin-password "$PW" \ --security-type TrustedLaunch \ --storage-sku StandardSSD_LRS \ --public-ip-sku Standard \ --nsg "${PROJECT}-nsg" \ --nsg-rule NONE ``` Gotchas baked into that: - The disk flag is `--storage-sku`. The obvious-sounding alternative errors. - Windows 11 images **require** `TrustedLaunch` for TPM and secure boot. - `--nsg-rule NONE` opens nothing; lock ports to your own address next. - **VM size availability changes without warning.** Sizes that worked last month can return `NotAvailableForSubscription`. List what is actually unrestricted before creating rather than trusting a size recorded in a document — including this one. - **The CLI masks real failures.** A generic client-side error often hides the actual reason. Re-run with `--debug`, or read the activity log for the resource group, to get the real message. Lock inbound access to your current public address, and remember **that address drifts** — if SSH or RDP suddenly times out, re-check it before debugging anything else. ## Getting an interactive session, which is the whole difficulty SSH lands in a **non-interactive** session. Anything touching the visible desktop — wallpaper, UI, screenshots, global hotkeys — must run in the **interactive console session**, which requires autologon to exist at all. Set up the SSH server and autologon through the cloud provider's run-command mechanism, which executes as SYSTEM and needs no inbound access. Two specifics that cost time: - **The SSH capability's own firewall rule covers the private profile only**, while a cloud network is public. Without an all-profiles rule, `sshd` reports running and connections time out. - **Windows OpenSSH authenticates a local admin against a machine-wide authorized-keys file**, not the per-user one, and it needs a locked-down ACL — inheritance removed, granting only Administrators and SYSTEM. **The default SSH shell varies by image.** It may be PowerShell; on one 24H2 build it was `cmd.exe`. Do not assume. Check once, and for anything non-trivial copy a `.ps1` across and invoke PowerShell explicitly rather than inlining a large script — that dodges several layers of quoting between your local shell, SSH and the remote shell, which is especially unpleasant with passwords containing special characters. To act on the visible desktop, create a **scheduled task that runs interactively as the logged-in user**, then run it. ### Screenshots that actually contain your application A full-screen grab on a fresh VM is frequently dominated by the first-run privacy dialog. Capturing a single window is more reliable. Capture the window's device context directly, and **pass the render-full-content flag — without it, GPU-composited content such as an embedded web view comes out black.** Restore the window first if it might be minimised. This also sidesteps z-order fights and the first-run dialog entirely. Two more session realities: - **Global hotkeys need a low-level key event** from an interactive task. The high-level send-keys approach does not fire a system-wide shortcut. - **A tray or GUI application has no stderr.** Console logging vanishes. If you need to know what a background thread did, have the application write its state to a file and read that back. ## Testing as a genuine standard user This is the section worth the whole document, because the obvious approach produces a passing test that proves nothing. The question is usually "can a user-context application write a protected file after the installer granted an ACL?". Every convenient context is **elevated**, so a successful write tells you nothing about the ACL. - **The admin user is useless for this.** Its SSH session runs at high integrity, and a scheduled task as that user **also gets a high-integrity token even when the task is configured to run with limited privileges.** An elevated process writes protected files regardless of the ACL under test. Always print the integrity level inside the probe: if it reports elevated, the result is inconclusive, not a pass. - **Create a real standard user.** The legacy `net user` command **rejects passwords longer than fourteen characters** with a confusing error. Use the PowerShell cmdlet instead, then add the account to Users. - **Launching a process as another user from SSH fails** with a DLL initialisation error — the child cannot attach to a window station in a service session. Do not go down this route. - **Use a scheduled task as the standard user** — but a fresh account lacks the "log on as a batch job" right, so the task silently does not run, leaving an empty result and a non-zero last-result code. Grant it by adding the user to **Performance Log Users**, which carries batch-logon rights **and no file-access-bypass privilege**. **Do not use Backup Operators.** Its backup privilege bypasses ACL checks entirely, which would make *any* write succeed and silently invalidate the exact thing you are testing. This is the trap: the group that looks like it grants "permission to run things" also grants permission to ignore permissions. - **The task's stdout is lost.** Have the script write a result file somewhere the standard user can write, then read it back over SSH. ### Verifying an installer's ACL side effects Capture before and after, and **distinguish explicit from inherited entries** — `icacls` marks inherited ones. An installer-added grant is explicit; a correct uninstaller removes it, leaving only the inherited baseline: ``` before: BUILTIN\Users:(I)(RX) inherited read only after: BUILTIN\Users:(M) + BUILTIN\Users:(I)(RX) explicit Modify added uninst: BUILTIN\Users:(I)(RX) explicit entry gone ``` Zero residue after uninstall is the assertion. "The uninstaller ran without error" is not. ## The per-user versus per-machine installer trap If you deploy an installer the way an organisation would — from SYSTEM context — a **per-user installer installs into the SYSTEM account's profile.** It reports success, exits zero, and is **invisible to every real user on the machine.** You then spend an afternoon debugging a ghost while the application under test runs a stale copy from somewhere else entirely. Check where the installer actually landed before believing an exit code. ## The general lesson Almost everything above is a variant of one thing: **the convenient context is not the context your users are in.** Elevated sessions, SYSTEM installs, an admin's scheduled task and an unquarantined file all behave differently from what a real user gets, and all of them fail in the direction of a passing test. When a test passes, the useful question is whether it *could* have failed.