Research & technology

E2exe: How we solved Windows desktop automation for enterprise scale

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:

  • Every test needs a full VM because Windows containers don’t include a graphical desktop to run the app.
  • The machines are slow to start. A cold Windows VM takes 2 to 3 minutes to boot. A browser container takes seconds.
  • Someone has to be logged in. Windows UI Automation (UIA), the accessibility interface that test tools use to find buttons and fields, only works inside an active, logged-in desktop session. A machine that's merely switched on isn't enough.
  • Machines remember things. Installed files, registry keys, user profiles, saved logins and stray processes all survive between runs.
  • Every app brings its own world. Installers, admin prompts, licensed dependencies, first-run dialogs, sign-ins, etc.
  • There's no built-in visibility. A browser hands you traces, logs and screenshots. A remote Windows machine hands you nothing unless you build it.
  • The tooling assumes Linux. Kubernetes, CI runners and most test infrastructure were designed around Linux containers.

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 Architecture

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:

  1. A controller running in Kubernetes. It keeps a pool of Windows VMs on Google Compute Engine (GCE) and hands them out to tests.
  2. A runner, a Linux pod that runs the test code.
  3. A Windows VM that runs the app under test, the automation tools and a small desktop agent.

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:

  1. The test starts in a Linux runner pod, which asks for a Windows device.
  2. The scheduler assigns it a VM from a pool of machines that are already booted and logged in.
  3. The runner receives that VM's connection addresses for CDP, Appium, and screen streaming.
  4. The app under test is delivered to the VM and launched.
  5. The test runs. The screen streams live and is recorded.
  6. The test finishes, the claim is released, and the VM is destroyed. A fresh VM takes its place in the pool.

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.

Renting Windows Machines on Demand

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.

Supplying Clean VMs Quickly

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:

  • Every VM is single-use. A VM is never reused after a test. No test can see another test's files, settings or logins, so a whole category of "passes on its own, fails in the suite" problems never comes up.
  • Warm pools absorb the boot time. Each pool keeps VMs booted, logged in and waiting, so a test claims a machine that's already ready and skips the 2 to 3 minute cold start. The pool replaces machines as tests use them up.
  • Abandoned machines get reclaimed. A claimed VM reports activity while its test runs. If it goes quiet for 60 seconds, the controller assumes the test is gone and reclaims the machine.
  • Capacity shortages move zones and regions. If a cloud zone can't supply a Windows VM, the pool creates it in another zone.
  • Updates happen by attrition. Because VMs are disposable, a new machine image needs no upgrade process. Old VMs get used up and replaced with new ones, and idle VMs on an outdated image are recycled, so the pool gradually moves over on its own.

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.

A Windows Image Built to Be Automated

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.

Test Code Stays on Linux

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.

Getting the App Onto a Brand-New Machine

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.

Seeing Inside a Remote Desktop

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:

  • Live viewing of a Windows test while it runs.
  • A video recording of every run.
  • An element chooser for native apps, for picking UIA elements when writing a test.
  • Screenshots, actions and element inspection through the QA Wolf MCP (Model Context Protocol) server and public API, so AI agents can work with Windows runs the same way they work with browser runs.

Keeping Customer Networks Closed

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.

  • One known IP range. VM traffic leaves from QA Wolf's own IP range, so a customer allowlists one range instead of a moving set of cloud addresses.
  • Private pools stay private. A dedicated pool can only be claimed by the workspace it belongs to.
  • No stored cloud keys. The controller authenticates to Google Cloud through Workload Identity, so no long-lived credentials sit on a machine.
  • Nothing persists. Because VMs are destroyed after each test, no customer data stays on a machine after a run.

Add Windows to Your E2E Test Suite Today

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.

More from the blog

All posts