Skip to content
sharpSteff's Blog
Go back

Provisioning Jenkins Windows Nodes for .NET Builds and UI Testing with Ansible

Setting up a Windows build node for a .NET project is mostly an exercise in installing the right tools: the SDK, Git, a few global tools, an MSI toolchain. Tedious, but straightforward. Where it gets genuinely interesting is the moment you add automated UI testing to the same node, because the way Jenkins normally runs an agent on Windows is fundamentally incompatible with how GUI automation works.

This post walks through how I provision these nodes with Ansible, and digs into the one problem that took the most thought: getting UI tests to actually see a desktop.

Table of contents

Open Table of contents

The two jobs of a build node

The nodes I’m describing have to do two quite different things:

  1. Headless work: dotnet build, dotnet test, dotnet publish, NuGet restores, MSI installer creation, archiving artifacts, generating license files. None of this needs a screen.
  2. Windows UI tests: an NUnit3 test project that drives a real WPF application with UIA3: finding controls, moving the mouse, sending keystrokes, taking screenshots, asserting on what’s actually on screen.

The first category is the easy 90%. The second is where the design of the node really matters.

Why the usual Jenkins agent setup breaks UI tests

The default, sensible way to run a Jenkins agent on Windows is as a Windows service. It starts on boot, restarts on failure, runs without anyone logged in. For headless builds this is exactly what you want.

But Windows services run in Session 0, an isolated session with no interactive desktop. This isolation was introduced back in Windows Vista for good security reasons, and it has a hard consequence for UI automation: there is no desktop to automate.

In practice, that means:

You can spend a long time debugging “flaky” UI tests before realizing the tests were never flaky. The agent simply had no screen to drive. No amount of retry logic fixes a black screenshot.

So the requirement becomes: the agent must run inside an interactive, unlocked desktop session that exists at all times, surviving reboots, with nobody physically sitting at the machine.

The approach: auto-logon + a logon-triggered scheduled task

The combination that solves this cleanly:

  1. Auto-logon a dedicated account on boot, so a real interactive session always exists after a reboot.
  2. Start the Jenkins agent from a scheduled task triggered on logon, running in that interactive session, not as a service.
  3. Keep the session alive: no lock screen, no screensaver, no display/sleep power-downs, and a fixed screen resolution.

Let me take those in turn.

A dedicated, least-privilege agent account

The agent runs as its own local account rather than as an administrator. A standard user is perfectly capable of running the agent and driving the desktop for UI tests, so that’s the default. It only gets Modify rights on the agent’s working directory and nothing more. Elevation is opt-in, for the rare case where the application under test itself needs to run elevated.

- name: Create dedicated Jenkins agent user
  win_user:
    name: "{{ jenkins_agent_user }}"
    password: "{{ jenkins_agent_password }}"
    password_never_expires: true
    groups: "{{ ['Administrators'] if jenkins_agent_admin else ['Users'] }}"
    groups_action: replace
    state: present
  no_log: true

- name: Grant the agent user modify rights on the Jenkins directory
  win_acl:
    path: "{{ jenkins_agent_dir }}"
    user: "{{ jenkins_agent_user }}"
    rights: Modify,Synchronize
    type: allow
    state: present

Auto-logon so a session always exists

After a reboot there’s nobody to log in, so the machine logs itself in. This is the classic Winlogon registry approach:

- name: Configure auto-logon for the agent account
  win_regedit:
    path: HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
    name: "{{ item.name }}"
    data: "{{ item.data }}"
    type: string
  loop:
    - { name: AutoAdminLogon,   data: "1" }
    - { name: DefaultUserName,  data: "{{ jenkins_agent_user }}" }
    - { name: DefaultPassword,  data: "{{ jenkins_agent_password }}" }
    - { name: DefaultDomainName, data: "{{ jenkins_agent_domain }}" }
  no_log: true

There’s an honest trade-off here worth calling out: AutoAdminLogon stores the password in plain text in the registry. For a locked-down, single-purpose CI node on an internal network this is often an acceptable risk, but if it isn’t acceptable for you, the hardened alternative is Sysinternals Autologon.exe, which stashes the password as an LSA secret instead. The playbook lets you turn the registry method off (configure_autologon: false) and configure Autologon separately.

One more detail: Windows can be configured to auto-logon only a fixed number of times via AutoLogonCount. Removing that value keeps auto-logon permanent, which is what you want for a node that may reboot for Windows Updates and must come back unattended.

Launch the agent on logon, in the interactive session

Now that a session reliably exists, the agent starts from a logon-triggered scheduled task rather than a service. The trigger fires for the agent user, and the task runs with an interactive_token so it lands in the real desktop session:

- name: Create Jenkins agent logon task (runs in interactive session)
  win_scheduled_task:
    name: JenkinsAgent
    actions:
      - path: powershell.exe
        arguments: >-
          -NoProfile -ExecutionPolicy Bypass
          -File "{{ jenkins_agent_dir }}\jenkins-agent.ps1"
    triggers:
      - type: logon
        user_id: "{{ jenkins_agent_user }}"
    username: "{{ jenkins_agent_user }}"
    logon_type: interactive_token
    run_level: "{{ 'highest' if jenkins_agent_admin else 'limited' }}"
    state: present
    enabled: true

The launch script itself downloads agent.jar from the controller if it’s missing and connects over WebSocket (which is friendly to firewalls and proxies, since it rides over the same HTTP(S) port as the controller rather than a separate inbound TCP port):

$AgentJar = Join-Path $AgentDir "agent.jar"
if (-not (Test-Path $AgentJar)) {
    (New-Object System.Net.WebClient).DownloadFile("$JenkinsUrl/jnlpJars/agent.jar", $AgentJar)
}

$JavaArgs = @(
    "-jar", $AgentJar,
    "-url", $JenkinsUrl,
    "-name", $AgentName,
    "-workDir", $WorkDir,
    "-webSocket",
    "-secret", $Secret
)
& java @JavaArgs

Keep the desktop usable for automation

A logged-in session isn’t enough on its own. Windows will happily lock it, blank it with a screensaver, or power down the display, and any of those will sink a UI test. So the node disables all of it:

- name: Disable the lock screen
  win_regedit:
    path: HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization
    name: NoLockScreen
    data: 1
    type: dword

- name: Disable display, sleep and hibernate power-downs (AC)
  win_command: "{{ item }}"
  loop:
    - powercfg /change monitor-timeout-ac 0
    - powercfg /change standby-timeout-ac 0
    - powercfg /change disk-timeout-ac 0
    - powercfg /change hibernate-timeout-ac 0

There’s also the resolution problem. A headless or RDP-disconnected machine frequently comes up with a tiny or unusable virtual resolution, which wrecks any test that clicks at fixed coordinates. The agent launch script forces a known resolution (e.g. 1920x1080) via a small ChangeDisplaySettings P/Invoke before the agent starts, so every run begins from the same, predictable screen geometry.

The headless 90%: installing the toolchain

With the hard part out of the way, the rest of the provisioning is a list of tool installs, mostly via Chocolatey. A representative slice:

- name: Install latest Git for Windows
  win_chocolatey:
    name: git
    state: latest

- name: Install .NET SDK
  win_chocolatey:
    name: "dotnet-{{ item }}-sdk"
    state: present
  loop: "{{ dotnet_sdk_versions }}"

A few non-obvious lessons baked into this part:

The playbook finishes with a verification pass over every tool and only then reboots, which triggers the auto-logon, which fires the logon task, which connects the agent. If any tool fails verification, the reboot is skipped so you don’t kick a half-provisioned node.

Idempotency: don’t disrupt a node that’s testing

One subtle but important behavior: before redeploying the launch script or rebooting, the playbook checks whether the agent is already connected and running a job. If it is, it leaves the launch script and the reboot untouched. Re-running the playbook against a fleet shouldn’t yank a node out from under a test that’s mid-run. This makes the playbook safe to run repeatedly, the whole point of using Ansible in the first place.

- name: Check whether the Jenkins agent is already connected
  win_shell: |
    $state = (Get-ScheduledTask -TaskName 'JenkinsAgent' -ErrorAction SilentlyContinue).State
    $javaIds = (Get-Process java -ErrorAction SilentlyContinue).Id
    # ...established connection to the controller port?
    if ($state -eq 'Running' -and $conn) { 'CONNECTED' } else { 'NOT-CONNECTED' }
  register: jenkins_agent_precheck
  changed_when: false

Keeping secrets out of the repo

Nothing sensitive lives in source control. Every credential, the SSH password for provisioning, the agent account password, and the per-node inbound-agent secret, is read from environment variables (or an Ansible Vault file) at run time. In CI, those are masked pipeline secrets; locally, you export them (or source a git-ignored .env) before running. Each Jenkins node has its own inbound-agent secret, so the convention is one variable per host, suffixed with the node name.

The Ansible tasks that touch these values are marked no_log: true so the secrets never land in the job log either.

The other side: GitHub Actions does this for free

After all that ceremony, it’s worth pointing out the contrast: on a hosted CI runner, none of this is your problem. The exact same UI3 + NUnit3 + .NET stack runs on GitHub Actions’ windows-latest runners with no auto-logon, no scheduled task, no desktop plumbing at all, because each job gets a fresh, throwaway VM that already boots into an interactive desktop session. The runner agent is set up such that UI automation just works.

I maintain AvalonDock (a WPF docking library), and its CI workflow is a good apples-to-apples example: same UIA3/NUnit3/.NET combination, but the entire “node setup” collapses into a few lines of YAML:

name: CI
on:
  pull_request:
    branches: [master]

jobs:
  build-and-test:
    runs-on: windows-latest          # fresh VM with a desktop, every run
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: |
            9.0.x
            10.0.x

      - run: dotnet restore AvalonDock.sln
      - run: dotnet build AvalonDock.sln -c Release -warnaserror -m:1

      # Headless unit tests - everything except the UI category
      - run: >
          dotnet test AvalonDock.sln -c Release --no-build -m:1
          --filter "TestCategory!=UITest"
          --logger "trx;LogFileName=unit.trx"

      # UI tests - the UITest category, on the desktop the runner already has
      - run: >
          dotnet test AvalonDock.sln -c Release --no-build -m:1
          --framework net10.0-windows
          --filter "TestCategory=UITest"
          --logger "trx;LogFileName=UITest.trx"

      - uses: actions/upload-artifact@v4
        if: always()                  # keep results even when tests fail
        with:
          name: test-results
          retention-days: 14
          path: "**/*.trx"

A few things stand out when you put the two side by side:

Jenkins self-hosted nodeGitHub Actions windows-latest
Interactive desktopYou build it (auto-logon + logon task)Provided, per job
Agent lifecycleLong-lived, you patch & maintain itFresh, throwaway VM each run
ToolchainYou install it (Ansible/Chocolatey)Pre-baked image + setup-dotnet
State between runsPersists (good and bad)None, always clean
UI3 category splitSame --filter TestCategory trickSame --filter TestCategory trick
Cost / controlYour hardware, your network, full controlPer-minute, but zero ops

Notice the test invocation is essentially identical on both sides: the same dotnet test --filter "TestCategory=UITest" split between headless and UI tests, the same TRX loggers, the same -m:1 to keep parallelism from fighting over the single desktop. That’s the nice part: UITest tests don’t care where they run, as long as there’s a desktop. All the Jenkins work in this post is, fundamentally, about reproducing on your own hardware the one thing GitHub’s hosted runner hands you for free.

So why self-host at all? The usual reasons: hardware or licensed software that can’t live in the cloud, builds that need to reach an internal network, GPU or performance requirements, or simply cost at scale. When those apply, you’re back to owning the desktop problem, and an Ansible role is how you stop solving it by hand.

Wrapping up

The headline lesson, if there’s one to take away: UI automation needs a real, interactive, unlocked desktop, and the default Windows-service agent can’t give it one. Auto-logon plus a logon-triggered scheduled task is the combination that does, and once you’ve encoded all of it (plus the toolchain, the PATH quirks, the shared install locations, and the idempotency guards) into an Ansible role, standing up a new node goes from a day of clicking around to a single playbook run.

The black-screenshot rabbit hole is one I’d happily save someone else from falling into.


Share this post:

Previous Post
AvalonDock v5 Is Released: The Numbers Behind It
Next Post
AvalonDock v5 — The Biggest Undertaking Since 2020