Running on CI¶
Spectre can run live Compose Desktop tests on CI as long as the test JVM is a real desktop JVM.
GitHub Actions Linux runners are validated on Ubuntu 24.04 under xvfb for both scripted-RPC
tests and real-backend Compose Desktop tests.
Test JVM flags¶
Set these on the Gradle Test task that hosts Spectre, not only on the runner process:
tasks.withType<Test>().configureEach {
systemProperty("java.awt.headless", "false")
if (System.getProperty("os.name").lowercase().contains("linux")) {
systemProperty("skiko.renderApi", "SOFTWARE_COMPAT")
}
}
-Djava.awt.headless=false is required because Spectre needs the AWT toolkit. xvfb gives
Linux a display server, but it cannot help if the JVM has already decided to run headless and
ignore DISPLAY.
Honesty: xvfb-run proves the Linux X11 capture path (helper ximagesrc, JUnit
failure screenshots, agent screenshots, visual idle under a virtual framebuffer). It does
not prove Wayland portal consent, PipeWire one-frame capture, or restore-token behaviour.
When the host also has residual Wayland sockets or inherited Wayland env vars, Spectre still
routes to X11 for an Xvfb DISPLAY (#397). For portal coverage, run on a real Wayland
session separately. If detection ever disagrees with your setup, set
SPECTRE_CAPTURE_BACKEND=x11 (or wayland) explicitly.
-Dskiko.renderApi=SOFTWARE_COMPAT is required on GPU-less Linux runners. Without it, Skiko's
default OpenGL path can throw RenderException: Cannot create Linux GL context. The semantics
tree may still compose, so selectors such as findByTestTag can pass, but typed input can
silently no-op because the Skia surface is not fully wired. Software rendering is the reliable
path when the runner has no GPU.
macOS helper JVMs¶
On macOS, add -Dapple.awt.UIElement=true when you want the Spectre test JVM to stay out of the
Dock:
tasks.withType<Test>().configureEach {
if (System.getProperty("os.name").lowercase().contains("mac")) {
systemProperty("apple.awt.UIElement", "true")
}
}
This is safe with RobotDriver.synthetic(rootWindow = ...) and per-character typeText(...).
Synthetic typing posts key events into the window hierarchy and does not require the app to be
the foreground Dock application. Clipboard-backed pasteText(...) still needs
apple.awt.UIElement=false, because that path goes through macOS clipboard services.
macOS sandbox-exec runners¶
If your local agent, test harness, or CI wrapper launches Gradle inside a macOS
sandbox-exec profile, the sandbox must allow the desktop services used by AWT,
Swing, Compose Desktop, and java.awt.Robot. This is separate from JVM headless
mode: -Djava.awt.headless=false is still required, but it does not grant access
to the WindowServer, Core Animation, input, or screen-capture services.
A working profile needs Mach lookup access to these services:
| Service | Why Spectre/Compose Desktop needs it |
|---|---|
com.apple.hiservices-xpcservice |
AWT/Compose Desktop startup. Missing access commonly logs Connection Invalid error for service com.apple.hiservices-xpcservice. |
com.apple.windowserver.active |
WindowServer integration for desktop windows. |
com.apple.windowmanager.server |
Window-manager transactions. Without it, AppKit can log WMClientWindowManager: Invalid connection and SLSTransaction commit with no context. |
com.apple.CARenderServer |
Actual Core Animation painting. Without it, Frame.isVisible()/Frame.isShowing() can both be true while no window appears on screen. |
com.apple.tsm.uiserver |
Text Services Manager and input plumbing. |
com.apple.carboncore.csnameddata |
Carbon named data lookup used by the desktop stack. |
com.apple.dock.server |
Dock/window integration. |
com.apple.dock.fullscreen |
Full-screen/Dock integration used by AppKit. |
com.apple.iohideventsystem |
Real Robot input paths. Synthetic Spectre input does not need OS-level input delivery, but real RobotDriver() does. |
com.apple.tccd.system |
TCC checks, including screen-capture and accessibility-style checks. |
com.apple.replayd |
Robot.createScreenCapture(...), ScreenCaptureKit, and ReplayKit capture plumbing. Without it, a capture can hang after the Robot is created. |
com.apple.logd.events |
Log/event integration observed on the Robot/capture path. Treat com.apple.diagnosticd as noisy rather than a baseline requirement unless your harness proves otherwise. |
Think of the requirements in layers:
- Window creation needs HiServices, WindowServer/window-manager, Text Services, Carbon named data, and Dock integration.
- Actual painting needs
com.apple.CARenderServer. Java visibility state is not proof that macOS has painted the window. - Real
Robotinput and screen capture need the input/TCC/capture services, especiallycom.apple.iohideventsystem,com.apple.tccd.system, andcom.apple.replayd. - Synthetic Spectre input avoids OS-level mouse/keyboard delivery, but the JVM
still needs enough AWT/Compose Desktop access to create and paint the target
window. Screenshots and
waitForVisualIdle()still go through capture paths.
macOS Screen Recording permission is also required for capture. Grant it to the
app that launched the JVM — Terminal.app, iTerm2, IntelliJ IDEA, or your runner app —
then fully quit and restart that app. TCC can be granted correctly and capture can
still hang if the sandbox profile does not allow com.apple.replayd.
When validating sandbox profile changes, avoid stale Gradle daemons:
A normal ./gradlew spectreTest ... may reuse a daemon launched under an older
sandbox profile. Use ./gradlew --stop only deliberately: if your desktop app or
runner itself was launched via Gradle, stopping all Gradle daemons can kill that
parent process too.
For painting/capture debugging, a useful probe is a small always-on-top Swing frame
filled with a known colour, placed at a known location, followed by
Robot.createScreenCapture(Rectangle(x, y, 1, 1)). If Frame.isVisible() and
Frame.isShowing() are true but the sampled pixel is the desktop background,
look at com.apple.CARenderServer and com.apple.windowmanager.server. If the
capture hangs after creating the Robot, look at Screen Recording permission and
com.apple.replayd.
The macOS log tool cannot run inside sandbox-exec:
If your runner enforces sandbox-exec, provide a narrow diagnostic lane outside the
sandbox for read-only commands such as log show ..., /usr/bin/log show ...,
log stream ..., and /usr/bin/log stream .... Keep it read-only: reject shell
metacharacters and mutating subcommands such as log collect and log erase. If
your policy logger supports reason codes, label this escape clearly, e.g.,
macos-log-diagnostic-lane.
Useful diagnostics:
/usr/bin/log show --last 3m --style compact --predicate 'process == "java" OR eventMessage CONTAINS "Sandbox:" OR eventMessage CONTAINS "ClientCallsAuxiliary" OR eventMessage CONTAINS "deny"'
/usr/bin/log show --last 2m --style compact --predicate 'processID == <PID> OR eventMessage CONTAINS "<PID>"'
Useful patterns to search for include Sandbox: java(...) deny(1) mach-lookup,
Service "com.apple.CARenderServer" failed bootstrap look up,
WMClientWindowManager: Invalid connection, ScreenCaptureKit, ReplayKit,
com.apple.replayd, and kTCCServiceScreenCapture.
Runtime matrix (JBR / Temurin)¶
Per-PR CI stays on a single Temurin 21 JDK for speed. Compatibility across JBR 21, JBR 25, and Temurin LTS (toolchain major) on macOS, Linux, and Windows is covered by the scheduled runtime-matrix workflow (epic #215 / issue #216):
| Dimension | Values |
|---|---|
| Runtime | JBR 21, JBR 25, Temurin LTS |
| OS | macOS, Linux (xvfb), Windows |
| Suites | Contract corpus (all OSes); agent attach same-runtime on Linux/macOS + mixed vanilla↔JBR on Linux; Linux X11 recording smoke. Full attach UI e2e is hosted on Linux/macOS; Windows cells run non-UI transport/ACL agent tests (UI attach e2e is opt-in on physical desktops via -Pspectre.agent.attachE2e.allowWindows=true). |
Pins live in .github/jbr-pins.env
(JBRSDK / jdk package, not jbr_jcef). Bump procedure is in that file’s header comments.
actions/setup-java caches downloads per runner OS/arch via the composite action
.github/actions/setup-matrix-jdk.
The matrix is release-gated: release.yml calls the same workflow via workflow_call
before helper builds and publish. A red cell blocks the release; scheduled failures open
issues labelled runtime-matrix rather than vanishing into Actions noise.
Test/JavaExec workers are forced onto the matrix JDK via SPECTRE_MATRIX_JAVA_HOME
(root build.gradle.kts); modules still compile with jvmToolchain(21). Without that
env, Gradle would execute JBR 25 cells on a toolchain-provisioned JDK 21 and the matrix
would not prove runtime compatibility.
Mixed-runtime agent attach sets SPECTRE_FIXTURE_JAVA_HOME (or
-Pspectre.agent.fixtureJavaHome=…) so the fixture JVM can differ from the attacher; the
:agent:test task forwards it as
-Ddev.sebastiano.spectre.agent.fixtureJavaHome=… into the forked test JVM.
Recording tests¶
Keep recording tests tagged separately from normal UI tests. Linux CI can validate Xorg / xvfb
region recording and non-recording Spectre flows, but it cannot validate macOS
ScreenCaptureKit capture. The base spectre-recording artifact also does not carry the macOS
ScreenCaptureKit helper; macOS recording tests need the spectre-recording-macos runtime
artifact or a locally built helper artifact on the test classpath.
For example:
tasks.withType<Test>().configureEach {
useJUnitPlatform {
excludeTags("recording")
}
}
val macRecordingTest by tasks.registering(Test::class) {
useJUnitPlatform {
includeTags("recording")
}
onlyIf { System.getProperty("os.name").lowercase().contains("mac") }
systemProperty("java.awt.headless", "false")
}
GitHub Actions example¶
Install xvfb on Linux, then run the Gradle task through xvfb-run. Keep the JVM flags in
Gradle so local runs and CI use the same test process configuration.
jobs:
spectre-ui-tests:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run Spectre UI tests
run: xvfb-run --auto-servernum ./gradlew spectreTest
- name: Upload Spectre failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: spectre-failure-artifacts
path: "**/build/reports/spectre/**"
if-no-files-found: ignore
val spectreTest by tasks.registering(Test::class) {
description = "Runs live Compose Desktop UI tests with Spectre."
group = "verification"
useJUnitPlatform()
systemProperty("java.awt.headless", "false")
if (System.getProperty("os.name").lowercase().contains("linux")) {
systemProperty("skiko.renderApi", "SOFTWARE_COMPAT")
}
}
Failure artifacts¶
When a Spectre JUnit test fails, the extension/rule writes atomic captures under
build/reports/spectre/ (see JUnit integration). Upload that
tree on CI failure so you can open capture.json + screenshot.png without re-running the
suite:
- name: Upload Spectre failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: spectre-failure-artifacts
path: "**/build/reports/spectre/**"
if-no-files-found: ignore
if-no-files-found: ignore keeps the step green when every test passed or opt-out produced
no files. Prefer a multi-module glob (**/build/reports/spectre/**) over a single module path
when several projects run Spectre tests in one job.
Failure video uploads¶
Failure video writes failure-video.mp4 under the same
build/reports/spectre/ tree when the policy keeps a file. You still need the matching
spectre-recording-<os> runtime helper on the test classpath (see that page). Choose the
upload condition from the policy you configured:
FailureVideoPolicy.Off (default)¶
No recorder starts and no video files are written. Upload stills only on failure (same as Failure artifacts):
- name: Upload Spectre failure stills
if: failure()
uses: actions/upload-artifact@v4
with:
name: spectre-failure-artifacts
path: "**/build/reports/spectre/**"
if-no-files-found: ignore
FailureVideoPolicy.OnFailureKeep¶
Video is kept only when the test failed (deleted on pass). Upload on failure — stills and any kept videos share the same reports tree:
- name: Upload Spectre failure stills and videos
if: failure()
uses: actions/upload-artifact@v4
with:
name: spectre-failure-artifacts
path: "**/build/reports/spectre/**"
if-no-files-found: ignore
FailureVideoPolicy.Always¶
Videos are kept on pass and fail (not on assumption/abort skips). Use if: always() so
green jobs still publish videos; stills remain failure-only on disk, so the same glob is safe:
- name: Upload Spectre reports (including Always policy videos)
if: always()
uses: actions/upload-artifact@v4
with:
name: spectre-reports
path: "**/build/reports/spectre/**"
if-no-files-found: ignore
Without if: always(), a fully green job never runs a if: failure() upload step, so
Always videos would be left only on the runner filesystem and discarded when the job ends.
| Policy | When files remain on disk | Typical GHA if: |
|---|---|---|
Off |
Stills on fail only | failure() |
OnFailureKeep |
Stills + video on fail | failure() |
Always |
Stills on fail; video on pass and fail | always() for video-bearing uploads |