Goal
Why this matters
Prerequisites
UseHeadless and driving the dispatcher.Avalonia’s own integration pipeline (see external/Avalonia/azure-pipelines-integrationtests.yml:1) demonstrates the moving parts for Appium + headless test runs:
UseDotNet@2.node processes, start Appium on macOS; start WinAppDriver on Windows).dotnet test against Avalonia.IntegrationTests.Appium.csproj.appium.out on failure and TRX results on all outcomes.For GitHub Actions, mirror that setup with runner-specific steps:
jobs:
ui-tests:
strategy:
matrix:
os: [windows-latest, macos-13]
runs-on: $
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v3
with:
global-json-file: global.json
- name: Start WinAppDriver
if: runner.os == 'Windows'
run: Start-Process -FilePath 'C:\\Program Files (x86)\\Windows Application Driver\\WinAppDriver.exe'
- name: Restore
run: dotnet restore tests/Avalonia.Headless.UnitTests
- name: Test headless suite
run: dotnet test tests/Avalonia.Headless.UnitTests --logger "trx;LogFileName=headless.trx" --blame-hang-timeout 5m
- name: Publish results
if: always()
uses: actions/upload-artifact@v4
with:
name: headless-results
path: '**/*.trx'
Adjust the matrix for Linux when you only need headless tests (no Appium). Use the same dotnet test command locally to validate pipeline scripts.
Headless suites should run with parallelism disabled unless every fixture is isolation-safe. xUnit supports assembly-level configuration:
// AssemblyInfo.cs
[assembly: CollectionBehavior(DisableTestParallelization = true)]
[assembly: AvaloniaTestFramework]
Pair the attribute with AvaloniaTestApplication so a single HeadlessUnitTestSession drives the whole assembly. For NUnit, launch the test runner with --workers=1 or mark fixtures [NonParallelizable]. This avoids fighting over the singleton dispatcher and ensures actions happen in the same order on developer machines and CI bots.
Within tests, drain work deterministically. HeadlessWindowExtensions already wraps each gesture with Dispatcher.UIThread.RunJobs() and AvaloniaHeadlessPlatform.ForceRenderTimerTick(); call those directly from helpers when you schedule background tasks outside the provided wrappers.
Collect evidence automatically so failing builds are actionable:
.LogToTrace() in your AppBuilder. Redirect stderr to a file in CI (dotnet test … 2> headless.log) and upload it as an artifact.CaptureRenderedFrame (Chapter 40) to grab before/after bitmaps on failure. Save them with a timestamp inside TestContext.CurrentContext.WorkDirectory (NUnit) or ITestOutputHelper attachments (xUnit).record-video.runsettings (external/Avalonia/tests/Avalonia.IntegrationTests.Appium/record-video.runsettings:1) to capture Appium sessions; reuse it by passing /Settings:record-video.runsettings to VSTest or --settings to dotnet test.appium.out when a job fails (external/Avalonia/azure-pipelines-integrationtests.yml:27).UI tests occasionally hang because outstanding work blocks the dispatcher. Harden your pipeline with diagnosis options:
dotnet test --blame-hang-timeout 5m --blame-hang-dump-type full to trigger crash dumps when a test exceeds the timeout.HeadlessUnitTestSession.Dispatch so the framework can pump the dispatcher (external/Avalonia/src/Headless/Avalonia.Headless/HeadlessUnitTestSession.cs:54).Dispatcher.UIThread.RunJobs() and AvaloniaHeadlessPlatform.ForceRenderTimerTick() in a loop until a condition is met. Fail the test if the condition never becomes true to avoid infinite waits.DispatcherTimer callbacks or set DispatcherTimer.Tag to identify timers causing hangs; the headless render timer is labeled HeadlessRenderTimer (external/Avalonia/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs:21).Analyze captured dumps with dotnet-dump analyze to inspect managed thread stacks and spot blocked tasks.
CI agents often reuse workspaces. Add cleanup steps before running UI automation:
pkill IntegrationTestApp, pkill node) as the macOS pipeline does (external/Avalonia/azure-pipelines-integrationtests.yml:21).AVALONIA_RENDERER overrides). Keep your scripts explicit to avoid surprises when infra engineers tweak images.For cross-platform Appium tests, encapsulate capability setup in fixtures. DefaultAppFixture (external/Avalonia/tests/Avalonia.IntegrationTests.Appium/DefaultAppFixture.cs:9) configures Windows and macOS sessions differently while exposing a consistent driver to tests.
Publish TRX or NUnit XML outputs to your CI system so failures appear in dashboards. Azure Pipelines uses PublishTestResults@2 to ingest xUnit results even when the job succeeds with warnings (external/Avalonia/azure-pipelines-integrationtests.yml:67). GitHub Actions can read TRX via dorny/test-reporter or similar actions.
Send critical logs to observability tools if your team maintains telemetry infrastructure. A simple approach is to push structured log lines to stdout in JSON—CI services preserve the console by default.
CultureInfo.DefaultThreadCurrentUICulture is set for deterministic layouts.--blame dumps, then review stuck threads. Often a test awaited Task.Delay without advancing the render timer; replace with deterministic loops.UseHeadlessDrawing = false) so CaptureRenderedFrame works in pipelines.window.Close()), dispose CompositeDisposable, and tear down Appium sessions in Dispose. Lingering windows keep the dispatcher alive and can cause later tests to inherit state.dotnet restore, dotnet test, artifact copy). Run it before pushing so pipeline failures never surprise you.dotnet test --blame into your CI job and practice analyzing the generated dumps for a deliberately hung test.What's next