There’s a good reason why testing Windows desktop apps was one of the most requested features from customers: no one in the world has the infrastructure to parallelize thousands of runs per month.
Web testing infrastructure is relatively easy: a headless browser starts inside a Linux container in a couple seconds, costs a fraction of a cent to run, and gets thrown away when the test ends. There’s no equivalent for testing a Windows desktop app.
When testing Windows apps:
Our answer was to stop treating Windows machines as servers and treat them like phones: a device that one test claims, uses once, and throws away.
The basic architecture borrows from our mobile testing infrastructure, because mobile testing has the same constraints: the test code can't run on the device it's testing, and each test needs a device to itself while it runs. Since a Windows machine is modeled as a device in Kubernetes, a test can ask our custom device scheduler for a VM with a DeviceClaim, get its connection details back, control the device remotely through WebdriverIO and Appium, and release the machine when the test is done.
Windows needed two extensions to the device model: a device no longer has to live inside the Kubernetes cluster (a GCE VM lives outside it entirely), and a claim can return a desktop's connection addresses, including CDP for browsers. Otherwise, the Windows runner follows the mobile runner's lifecycle step for step (claim, heartbeat, record, connect, run, release), tests talk to native apps through the same WebdriverIO and Appium pairing, and private Windows pools work the way private iOS devices do.
Like the mobile system, it has three parts:
The runner talks to the VM over three connections. Playwright connects over CDP (Chrome DevTools Protocol) for anything in a browser. Appium handles native Windows apps. A WebRTC (Web Real-Time Communication) stream carries the screen so people can watch the test live and review a recording afterward.
Like an Android emulator, each Windows VM is disposable. Nobody logs into it, patches it or keeps it running for weeks. A test claims it, uses it once and gives it back, and a fresh VM takes its place.
A Linux runner pod runs the test code (@qawolf/flows/windows). Step 1: the test claims a device from the device scheduler, which runs with a Windows VM reconciler in the Kubernetes controller. Step 2: the scheduler assigns a ready Windows VM on Google Compute Engine (GCE) to the test. The scheduler also keeps a pool of warm Windows VMs ready through the GCE API. The test then talks to the claimed VM over three connections: CDP (Playwright) to a browser, and Appium (WebDriver) to Appium and WinAppDriver, which drive the app under test through UI Automation (UIA). A Go desktop agent on the VM sends a WebRTC screen stream back to the runner.
So now, when a user kicks off a run:
Step 1 of 6The test starts in a Linux runner pod, which asks for a Windows device.
Sequence between the runner pod (test code), the device scheduler (Kubernetes) and a Windows VM from the warm pool. Step 1: the runner sends a DeviceClaim to the scheduler. Step 2: the scheduler assigns a ready VM. Step 3: the scheduler returns the VM’s connection addresses for CDP, Appium and the screen stream. Step 4: the runner calls startApp(); the VM downloads the app with a signed URL, installs it and launches it. Step 5: the runner sends CDP and Appium commands; the VM streams its screen back over WebRTC for live view and recording, and sends heartbeats to the scheduler, which reclaims it if it goes quiet for 60 seconds. Step 6: the runner releases the claim, the scheduler destroys the VM and creates a fresh one for the warm pool.
The first thing a Windows test does is ask for a Windows machine. Handing those out is the job of our device scheduler, the same in-house service that already hands out Android emulators. For Windows, we taught it two new jobs: keeping a stock of VMs rented from Google Compute Engine, and giving one to each test that asks.
The scheduler tracks the fleet through three new Kubernetes custom resources:
WindowsVMTemplate describes what a VM looks like: the machine image, size and configuration.WindowsVMPool describes how many ready VMs to keep around for a given template.WindowsVM represents one machine.Records like these are how the device scheduler already manages emulators, so Windows VMs fit into it without a separate system. They also keep the fleet's state durable and visible. If the scheduler restarts, it reads the records and picks up where it left off, and engineers can inspect any pool or VM with standard Kubernetes tools.
Giving a machine out starts with a DeviceClaim, the same object tests use to claim an emulator. The scheduler matches the claim to a ready VM from the right pool and returns that VM's connection addresses to the runner: CDP for browsers, Appium for native apps and the screen stream.
Keeping machines in stock is the job of a reconciler inside the scheduler. It keeps comparing the records against what's actually running in GCE and closes the gap. If a pool is three machines short, it creates three. When a test releases its claim, it deletes that VM. Windows VMs take minutes to create and boot, so the reconciler moves each one forward a step at a time across short passes and writes its progress onto the VM's record. A slow boot never holds up anything else the scheduler manages.
The central tension in desktop test infrastructure is that clean machines are slow and fast machines aren't clean. Rebooting from a fresh image costs minutes, and reusing a machine means each test inherits whatever the last one left behind.
Our system handles both sides:
Making machines disposable removes a lot of work that normally comes with a Windows fleet: patching long-running machines, cleaning up after tests, and tracking down why one machine behaves differently from the others.
Native apps have no DOM for Playwright to drive, so a stock Windows install can't run automated UI tests. Appium, the Appium Windows driver and Microsoft's WinAppDriver have to be installed, configured and running inside a live desktop session before the first test arrives — all of which we bake into a base machine image. The image also runs a separate root-level session for parts of Windows that don't belong to the app, such as notifications and the Action Center.
A PowerShell setup script produces the image and publishes it to an image family in GCE so it's easily reproducible. Nobody hand-maintains a golden machine.
The base image includes common software like Google Chrome and Adobe Acrobat, which are frequently called during E2E tests but we also support variants of the base image. For example, an Office image clears Microsoft Word’s first-run dialogs before a test ever sees them.
When apps need more than that (licensed third-party software, specific configuration or other apps installed alongside them) we can build private images from a setup script the customer provides.
Everything else about running a QA Wolf test (runners, recordings, CI integration, result reporting) is built on Linux. Running test code on Windows would mean building and maintaining two copies of all of it. So test code doesn't run on Windows. It runs in a Linux runner pod and controls the VM remotely, and the VM only runs the app and the automation endpoints.
When the runner connects to its VM, it opens two channels to the same machine. Browsers on the VM are reached with Playwright's chromium.connectOverCDP(), and native apps through WebdriverIO and Appium.
Test authors see one API. The @qawolf/flows/windows entrypoint provides launch() for browsers and startApp() for native apps, and a single expect uses Playwright's matchers or WebdriverIO's depending on what it's asserting on. A test can sign in through a browser, switch to a native app and check the result without caring which protocol carries each step.
const app = await startApp("C:\\qawolf\\apps\\Invoicing.exe");
await app.$("~NewInvoiceButton").click();
await app.$("~CustomerName").setValue("Acme Corp");
await app.$("~SaveButton").click();
await expect(app.$("~StatusBar")).toHaveText("Invoice saved");
The practical effect is that a Windows test looks like any other QA Wolf test, and it runs through the same pipeline.
With single-use VMs, the app under test has to reach a new machine on every run, quickly and without opening a security hole. It also often arrives as an installer rather than something ready to launch.
The app's files never pass through the runner. When a test calls startApp(), the system issues a short-lived signed URL, and the VM downloads the app straight from storage over Private Google Access, a network path that stays inside Google's network. Only QA Wolf's central API service can sign those URLs. Test code runs as an unprivileged user and asks for downloads through a trusted parent process, so the code inside a test can't sign arbitrary URLs.
Once the file lands, the VM handles installation. It supports .exe, .msi and .msix installers, including passing MSI properties, so apps that only ship as installers work the same way as a standalone executable. Tests can also push files to the VM and pull files back, which covers flows like importing a document or checking an exported report.
A remote Windows VM is a black box by default. Writing a test, debugging a failure or reviewing a run all require seeing what the test saw, as it happened.
Each VM runs a small desktop agent written in Go. It captures the screen with FFmpeg and streams it over WebRTC, and it simulates mouse, keyboard and clipboard input by calling Windows' own user32.dll. The agent doesn't use CGo (Go's bridge to C code), so it cross-compiles for Windows from a Mac or Linux machine, and the platform-specific parts sit behind Go build tags. The Linux, Windows, and Mac agents share one codebase, and the Linux build is unaffected by the Windows code.
The agent reads the actual screen size from Windows instead of trusting configuration, so the stream always shows the whole desktop at the right scale. It also exposes diagnostics and logs endpoints that report what the capture pipeline and the WebRTC connection are actually doing.
For the people using QA Wolf, that adds up to:
Desktop apps often talk to systems behind a firewall: internal APIs, licensing servers, databases, etc. Customers need to know exactly what's connecting to them.
Windows desktop app testing is now live. It works through the QA Wolf platform and our MCP, using a single API for writing tests across web, iOS, and Android. One test can open a browser with Playwright, control a native app through WebdriverIO and Appium, and check the result with one expect, whether the app runs on the web, on mobile or on a Windows desktop.