Spectre
SkillAI & modelsUse when writing, debugging, or reviewing tests that drive a real on-screen Compose Desktop window with Spectre through semantics plus real/synthetic input, cooperative desktop leases, HTTP, or agent attach. Trigger on `ComposeAutomator`, `RobotDriver`, `AutomatorNode`, `InputLeasePolicy`, `InputIsolationConfig`, `spectre input-lock`, `AgentAttach`, `AttachedAutomator`, `findByTestTag`, `waitForNode`, `waitForIdle`, parallel JVM focus contention, cross-JVM Compose automation, screenshots/recording, IntelliJ/Jewel-hosted Compose, or `dev.sebastiano.spectre.*` imports. Also use for JUnit 4/5 tests that open a real top-level Compose window. Do not use for the off-screen `runComposeUiTest` / `createComposeRule` / `onNodeWithTag` framework.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Spectre skill
What this skill tells your AI
The instructions your AI receives, as published by rock3r/spectre in skills/spectre/SKILL.md and read by ahel’s review.
Spectre drives live Compose Desktop UIs from JUnit tests. It is not the
Compose Multiplatform test framework (runComposeUiTest / onNodeWithTag) —
that one runs an off-screen Compose tree on a virtual clock. Spectre opens a real
window, reads its semantics tree, and feeds it synthetic AWT events by default
(RobotDriver.synthetic()), or real OS input via java.awt.Robot when the test
opts in.
Pick Spectre when the test needs to exercise the actual window, popups, IntelliJ/Jewel-hosted Compose, or to record a real video of the UI.
User guide source of truth
Use the published docs as the source of truth when answering setup questions:
- Installation and coordinates: https://spectre.sebastiano.dev/guide/installation/
- Experimental desktop input coordination: https://spectre.sebastiano.dev/guide/input-coordination/
- Agent attach: https://spectre.sebastiano.dev/guide/agent/
- Cross-JVM HTTP transport: https://spectre.sebastiano.dev/guide/cross-jvm/
- Compose Hot Reload awareness (CLI/MCP only): https://spectre.sebastiano.dev/guide/hot-reload/
In the Spectre repository, the same pages live under docs/guide/.
Division of labor vs Compose Hot Reload MCP
When an agent has both Compose Hot Reload’s MCP and Spectre configured:
If you have HR available and want quick sanity checks while iterating on a live app, use the HR MCP; in any other case, Spectre is the right choice.
Do not use HR for repeatable tests. Spectre’s :testing / JUnit surface has no
waitForReloadSettled — that wait exists only on the interactive CLI/daemon/MCP tier.
The 30-second mental model
A test owns three things, in this order:
- A running Compose window. You launch your app or test harness yourself
(e.g.
application { Window(...) { … } }); Spectre does not host it for you. - A
ComposeAutomator. Built once per test viaComposeAutomator.inProcess()(usually through the JUnit extension/rule), it discovers Compose surfaces and reads their semantics. - Suspending input + synchronization calls against that automator. All
input methods (
click,typeText, etc.) and waits aresuspendfunctions — wrap the test body inrunSpectreTest { ... }(fromdev.sebastiano.spectre.testing).
There is no compose-test style auto-wait. Every findBy… call is a single
read against current state. You wait explicitly, then you query.
Minimal end-to-end example
import dev.sebastiano.spectre.testing.ComposeAutomatorExtension
import dev.sebastiano.spectre.testing.runSpectreTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
class CounterTest {
@JvmField
@RegisterExtension
val automatorExt = ComposeAutomatorExtension()
@Test
fun `clicking increment bumps the counter`(): Unit = runSpectreTest {
launchCounterApp() // your harness — opens the Compose window
val automator = automatorExt.automator
automator.waitForNode(tag = "CounterValue")
automator.waitForVisualIdle()
val initial = automator.findOneByTestTag("CounterValue")
check(initial?.text == "Count: 0")
val increment = automator.findOneByTestTag("Increment")
?: error("Could not find Increment button")
automator.click(increment)
automator.waitForVisualIdle()
val updated = automator.findOneByTestTag("CounterValue")
check(updated?.text == "Count: 1")
}
}
JUnit 4 users substitute ComposeAutomatorRule for ComposeAutomatorExtension
and @get:Rule for @RegisterExtension.
Choosing a RobotDriver
ComposeAutomator.inProcess() accepts a RobotDriver. The default is
synthetic AWT input. Three variants:
RobotDriver.synthetic()/RobotDriver.synthetic(rootWindow = someTopLevelWindow)— the default. Dispatches AWT events directly into the livejava.awt.Windowhierarchy. No global focus contention, so safe for parallel test JVMs and for IDE-hosted Compose where stealing the IDE focus would be hostile. For key events, Spectre uses the current AWT focus owner when available and otherwise falls back to the key-listening AWT descendant under the last pointer target or Compose host; this is what makes Compose DesktopTextFieldtyping work in macOS helper JVMs launched withapple.awt.UIElement=true, where AWT never grants aWindow.focusOwner. Does not see OS shortcuts (Cmd+Tab, system menus).RobotDriver()— explicit realjava.awt.Robotinput on the host. Highest fidelity, but contends for global focus. Use when a smoke must exercise OS shortcuts or focus handoffs. The no-argument form remains uncoordinated while the feature is experimental; when several processes genuinely require real input, useRobotDriver(InputLeasePolicy.Required)and readreferences/input-coordination.md.RobotDriver.headless()— refuses to send any input. For tests that only read the semantics tree (e.g. asserting a screen layout) without driving it.
import dev.sebastiano.spectre.core.ComposeAutomator
import dev.sebastiano.spectre.core.RobotDriver
val automator = ComposeAutomator.inProcess(
robotDriver = RobotDriver.synthetic(rootWindow = ideFrame),
)
Wiring a custom driver through the JUnit extension/rule
ComposeAutomatorExtension and ComposeAutomatorRule do not take a
robotDriver = … named argument. Their primary constructor takes a single
AutomatorFactory = () -> ComposeAutomator. Use the trailing-lambda form to
build the automator with the driver you want:
@JvmField
@RegisterExtension
val automatorExt = ComposeAutomatorExtension {
ComposeAutomator.inProcess(robotDriver = RobotDriver.headless())
}
Same shape for the JUnit 4 rule — ComposeAutomatorRule { ComposeAutomator.inProcess(...) }.
Finding nodes
Selectors all live on ComposeAutomator and return AutomatorNode (or a list
thereof). They do not wait — see the synchronization section.
Use, in order of preference:
findByTestTag(tag)/findOneByTestTag(tag)— relies onModifier.testTag("…")on the composable. The default. Most reliable.hasTag(tag)is the boolean form of the same snapshot read.findByText(text, exact = true)/findOneByText(...)— match semanticsText. Brittle to i18n; OK for affordances written in test harness code.hasText(text)is the boolean form of the same snapshot read.findByContentDescription(...)— accessibility descriptions.findByRole(Role.Button)— semantics roles.allNodes()/tree()/printTree()— for debugging.printTree()returns a human-readable dump; log it when a selector returnsnull.
AutomatorNode exposes testTag, text/texts, contentDescription(s),
role, isFocused, isDisabled, isSelected, editableText, plus
coordinates: boundsInWindow (Compose pixels, already density-scaled),
boundsOnScreen (screen pixels, post-HiDPI), centerOnScreen. Tree
navigation via children/parent. Compare boundsInWindow against
Dp.roundToPx() / LocalDensity-scaled values. For boundsOnScreen, only
compare sizes and coordinate deltas against dp figures; absolute x/y also
include the Compose surface origin, so subtract that (or prefer
boundsInWindow for window-relative position asserts).
Driving input
All suspend on ComposeAutomator:
click(node),doubleClick(node),longClick(node, holdFor = 500.milliseconds)moveTo(node),moveTo(x, y),moveBy(deltaX, deltaY)— button-up pointer moves for hover;moveByis relative to the last Spectre-issued pointer position and throws if none existsswipe(from, to, steps, duration)orswipe(startX, startY, endX, endY, …)scrollWheel(node, wheelClicks)typeText("hello")— types supported ASCII text via key events without using the clipboard. UsepasteTextfor large or Unicode strings.pasteText("hello")— pastes via the system clipboard. On macOS the clipboard write is async; Spectre polls until the clipboard reads back the requested text. Disable clipboard managers in CI, and do not useapple.awt.UIElement=truefor JVMs that need clipboard-backed paste.typeTextis the preferred UIElement-safe path for supported ASCII because it uses per-character key events and does not touch the clipboard.clearAndTypeText(node, "new")— Ctrl/Cmd+A, Backspace, thentypeText.pressKey(KeyEvent.VK_ENTER, modifiers = 0),pressEnter().performSemanticsClick(node)— bypasses the OS entirely and invokes the ComposeOnClicksemantics action. Last resort for click-only flows or strictly headless contexts: it only clicks (no typing, no key events, no scrolling), so it can't fully replace OS input for most tests. For parallel-JVM focus contention, preferRobotDriver.synthetic(rootWindow)— especially as soon as the test also types text.focusWindow(node)— raises and focuses the window hostingnode. Over attach, useAttachedAutomator.focusWindow(nodeKey)(#364) beforepressKey/typeTextwhen the target app may not own OS keyboard focus.
Synchronization — the part everyone gets wrong
This is the #1 source of flakes. Internalize three rules:
Rule 1: queries don't wait
findByTestTag("Submit") / hasTag("Submit") read whatever is in the semantics
tree right now. If the screen hasn't rendered yet, they return empty / false.
Always wait before querying state that depends on a prior action.
Rule 2: pick the right wait
waitForNode(tag = "...", timeout = 5.seconds)— wait until a node with the given tag (or text) exists. Throws on timeout. Use this when a new node must appear.waitUntilGone(tag = "...", timeout = 5.seconds)— wait until no node with the given tag (or text) exists in any tracked window. Refreshes windows before every poll, so a popup that closes its ownWindowcounts as gone. ThrowsIllegalStateExceptionnaming the selector on timeout. Use this after dismissing a popup, menu, or dialog.waitUntil(description = "...") { ... }— wait until a predicate on theAutomatorTreesnapshot holds. The lambda's receiver is the tree, so phrase the condition withwindows()/allNodes()/roots(); every poll re-reads it (refreshing windows first). ThrowsIllegalStateExceptionnaming yourdescriptionon timeout, so write it as the state you were waiting for. Use this for barriers no tag/text selector expresses — a node count, a comparison, a combination. It is scoped to Spectre-observable state: wait for a service flag, a file, or an HTTP response with the tool that owns it, not in here.waitForIdle()— wait until the semantics fingerprint stabilizes and all registeredAutomatorIdlingResources are idle. Use this when you've triggered work that updates semantics but no specific node appears.waitForVisualIdle(stableFrames = 3)— wait until tracked Compose surface pixels are stable for N consecutive frames. Withspectre-recordingpresent, samples use the same window-scoped native still path asscreenshot(windowIndex)(not a screen-region crop). Heavier thanwaitForIdle. Use it before screenshotting, or when work is animation-bound rather than semantics-bound.
A typical pattern after an interaction is waitForVisualIdle(). After
triggering a screen change (e.g. opening a dialog), waitForNode(tag = …)
is more precise.
Rule 3: never call any wait on the EDT
All five of waitForNode, waitUntilGone, waitUntil, waitForIdle, and
waitForVisualIdle actively reject being called from the AWT event dispatch
thread — they need to invokeAndWait onto the EDT to read state, so running
them on the EDT would deadlock. If your dispatcher is Swing-backed (the IntelliJ EDT dispatcher, a
custom Swing dispatcher, etc.), wrap the wait in
withContext(Dispatchers.Default) { … } (or any non-EDT dispatcher) so the
wait suspends off-thread. The user docs you may have read elsewhere have an
older carve-out for waitForNode — that exception is gone in current code.
Rule 4: use runSpectreTest, not runTest
kotlinx-coroutines-test's runTest skips delay(). That collapses
longClick hold durations, swipe pacing, and the macOS clipboard-settle
poll inside pasteText to zero, breaking them all. Use runSpectreTest { ... }
in the test body.
Rule 4b: force expression-body tests to return Unit
Write @Test fun mySpec(): Unit = runSpectreTest { ... }. JUnit 5.14+ rejects
non-void test methods during discovery, and Kotlin infers an expression-body
function's return type from the last expression in the runSpectreTest body. Some
assertions return the asserted value, not Unit.
Custom idling resources
Background work that doesn't tick the semantics tree (custom animations,
network calls) is invisible to waitForIdle. Register an
AutomatorIdlingResource so the wait knows about it:
automator.registerIdlingResource(myResource)
try {
// ...
automator.waitForIdle()
} finally {
automator.unregisterIdlingResource(myResource)
}
Screenshots
automator.screenshot() returns a BufferedImage. Three forms:
automator.screenshot() // full desktop
automator.screenshot(region = Rectangle(x, y, w, h))
automator.screenshot(node) // node bounds
automator.screenshot(windowIndex = 0) // a tracked window
Always waitForVisualIdle() immediately before screenshotting — otherwise
you may capture a mid-animation frame.
On macOS, screenshots need an unlocked console session in addition to Screen
Recording TCC. A locked screen can make Robot.createScreenCapture return black
pixels; current Spectre checks the macOS IOConsoleLocked flag first and tells
the caller to unlock/retry before pointing at TCC.
screenshot(node) and screenshot(windowIndex) are native window-scoped still
screenshots and require :recording; they fail clearly rather than silently cropping the
framebuffer when no native backend is available. Use screenshot(region) only when a
screen-region capture is intended. AutoScreenshotter remains the direct top-level-window
API. This is separate from video recording:
- macOS:
ScreenCaptureKitScreenshotterviaspectre-recording-macos. - Windows:
WindowsWindowScreenshotterfor stills andWindowsGraphicsCaptureRecorderfor window/region video via the framework-dependent Windows Graphics Capture helper packaged inspectre-recording-windowsfor x64 and arm64; requires Windows 10 version 1903 or newer, .NET 8 Desktop Runtime, and Windows App Runtime 1.8 at runtime, plus .NET 8 SDK when building from source / CI. - Linux X11: Linux helper (
ximagesrc) for stills; the target must be visible/frontmost. - Linux Wayland: Linux portal helper for stills and window-targeted video.
Recording, JUnit, IntelliJ-hosted Compose
These each have their own reference. Read the file only when the task touches that area; they are not needed for the common case.
- Still window screenshots and video recording →
references/recording.md—AutoScreenshotter,AutoRecorder, platform helper artifacts (spectre-recording-macos/-linux/-windows), region vs window targeting, frame-drop and HiDPI traps. - JUnit 4 vs JUnit 5 integration →
references/junit.md—ComposeAutomatorExtension,ComposeAutomatorRule, parameter resolution, lifecycle. - Experimental desktop input coordination →
references/input-coordination.md— real-input contention across JVMs,InputLeasePolicy, JUnitInputIsolationConfig, runtime dependency, exact revoke, and unsafe forced recovery. - IntelliJ/Jewel-hosted Compose →
references/intellij.md— running the automator from anAnActionagainst the IDE frame, the pooled-thread requirement, and thesyntheticdriver. If the work also involves Jewel popups,ComposePanelembedding, orSwingBridgeTheme, the repo-localjewel-swing-interopskill applies as well. - Java-agent attach →
references/agent.md—AgentAttach,AttachedAutomator,AttachOptions, thespectre-agent/spectre-agent-runtimesplit, runtime jar auto-discovery, custom attach paths, and inject when the target has Compose but no preinstalledspectre-core.
Common pitfalls (memorise these)
| Symptom | Cause | Fix |
|---|---|---|
findOneBy… returns null right after an interaction | Selectors don't wait | Add waitForNode(...) or waitForVisualIdle() first |
Test deadlocks or throws inside any waitFor… / waitUntil… | Called from the AWT EDT | Wrap in withContext(Dispatchers.Default) { … } — applies to all five waits, including waitForNode, waitUntilGone, and waitUntil |
longClick/swipe/typeText complete instantly and miss | Test body uses runTest | Switch to runSpectreTest |
| Two parallel test JVMs steal focus from each other | Both opted into real RobotDriver() | Stay on the default RobotDriver.synthetic() (or pin rootWindow) |
| Parallel JVMs must preserve real OS input behavior | Real focus/pointer/clipboard state is shared across processes | Opt in to experimental coordination, add spectre-input-coordinator-server at runtime, use Required and JUnit InputIsolationConfig.perTest() |
| Cmd+Tab or OS shortcuts don't work under synthetic driver | Synthetic events bypass HID | Use real RobotDriver() for those tests |
| Screenshot is blurry / mid-animation | Captured before frame stabilised | waitForVisualIdle() first |
| Windows WGC helper missing or fails to start | spectre-recording-windows is not on the runtime classpath, the helper was not built/staged locally, or .NET / Windows App Runtime is missing | Add testRuntimeOnly("dev.sebastiano.spectre:spectre-recording-windows"); install .NET 8 Desktop Runtime + Windows App Runtime 1.8 for runtime; for source builds install .NET 8 SDK and run :recording-windows:verifyRecordingWindowsHelper |
typeText silently does not land on macOS with apple.awt.UIElement=true | Usually stale Spectre or wrong rootWindow; current synthetic input should target the Compose key-listening host even without AWT focus | Verify the test uses a Spectre build with the UIElement synthetic-key fallback, click/focus the field first, and pass the top-level host window to RobotDriver.synthetic(rootWindow) |
pasteText silently does not land on macOS with apple.awt.UIElement=true | UI-element/helper mode breaks clipboard-backed paste, even with synthetic input | Disable apple.awt.UIElement=true for the JVM hosting the test window, or use typeText for supported ASCII |
pasteText times out on macOS in CI | Clipboard manager rewriting NSPasteboard | Disable clipboard utilities in CI |
| Recording misses popups that escape the host window | Popups live in their own AWT window outside both the region rectangle and a window-targeted capture | Choose an explicit region (or full-desktop crop) wide enough to include where the popup opens, or document the limitation — neither region nor window targeting follows cross-window popups |
Window-targeted Wayland recording throws IllegalStateException | xprop missing or non-GNOME compositor | Use AutoRecorder.startRegion(...) with an explicit rectangle |
Coordinates derived from boundsInWindow land off-target on HiDPI | boundsInWindow is Compose pixels (already density-scaled) and window-relative; AWT/Robot and recording regions use screen pixels | Use boundsOnScreen/centerOnScreen for input and region targeting. If converting yourself, divide by the display scale and add the surface/panel screen origin — do not multiply |
What Spectre is NOT (don't pretend it is)
- If a tagged release is not available on Maven Central yet, consumers wire Spectre as a composite build or local clone. Check the installation guide before giving coordinates.
- It is not
compose-test/runComposeUiTest/onNodeWithTag. Don't mix those APIs into a Spectre test. - It does not capture audio. Recording is video-only.
- The cross-JVM HTTP server is experimental and security-caveated — do not recommend it for general use without flagging that.
- Desktop input coordination is experimental, cooperative, and opt-in. Do not call it an OS input
grab, imply
RobotDriver()coordinates by default, or suggest--forcewithout itsunsafeTakeover=trueoverlap warning. - It does not auto-wait on queries. Don't write tests that assume it does.
When unsure, dump the tree
The single most useful debugging primitive:
println(automator.printTree())
Run it right before a failing selector. The output names every node Spectre can see, with tags/texts/bounds. 90% of "selector returned null" mysteries resolve here. If it returns an empty string, the composition probably crashed before any node registered; check test stderr for EDT/composition exceptions.
Signals
- GitHub stars
- 35
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
spectre- Source
- github.com/rock3r/spectre