Spectre

SkillAI & models

Use 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.

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:

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:

  1. A running Compose window. You launch your app or test harness yourself (e.g. application { Window(...) { … } }); Spectre does not host it for you.
  2. A ComposeAutomator. Built once per test via ComposeAutomator.inProcess() (usually through the JUnit extension/rule), it discovers Compose surfaces and reads their semantics.
  3. Suspending input + synchronization calls against that automator. All input methods (click, typeText, etc.) and waits are suspend functions — wrap the test body in runSpectreTest { ... } (from dev.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 live java.awt.Window hierarchy. 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 Desktop TextField typing work in macOS helper JVMs launched with apple.awt.UIElement=true, where AWT never grants a Window.focusOwner. Does not see OS shortcuts (Cmd+Tab, system menus).
  • RobotDriver() — explicit real java.awt.Robot input 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, use RobotDriver(InputLeasePolicy.Required) and read references/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:

  1. findByTestTag(tag) / findOneByTestTag(tag) — relies on Modifier.testTag("…") on the composable. The default. Most reliable. hasTag(tag) is the boolean form of the same snapshot read.
  2. findByText(text, exact = true) / findOneByText(...) — match semantics Text. Brittle to i18n; OK for affordances written in test harness code. hasText(text) is the boolean form of the same snapshot read.
  3. findByContentDescription(...) — accessibility descriptions.
  4. findByRole(Role.Button) — semantics roles.
  5. allNodes() / tree() / printTree() — for debugging. printTree() returns a human-readable dump; log it when a selector returns null.

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; moveBy is relative to the last Spectre-issued pointer position and throws if none exists
  • swipe(from, to, steps, duration) or swipe(startX, startY, endX, endY, …)
  • scrollWheel(node, wheelClicks)
  • typeText("hello") — types supported ASCII text via key events without using the clipboard. Use pasteText for 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 use apple.awt.UIElement=true for JVMs that need clipboard-backed paste. typeText is 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, then typeText.
  • pressKey(KeyEvent.VK_ENTER, modifiers = 0), pressEnter().
  • performSemanticsClick(node) — bypasses the OS entirely and invokes the Compose OnClick semantics 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, prefer RobotDriver.synthetic(rootWindow) — especially as soon as the test also types text.
  • focusWindow(node) — raises and focuses the window hosting node. Over attach, use AttachedAutomator.focusWindow(nodeKey) (#364) before pressKey / typeText when 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 own Window counts as gone. Throws IllegalStateException naming the selector on timeout. Use this after dismissing a popup, menu, or dialog.
  • waitUntil(description = "...") { ... } — wait until a predicate on the AutomatorTree snapshot holds. The lambda's receiver is the tree, so phrase the condition with windows() / allNodes() / roots(); every poll re-reads it (refreshing windows first). Throws IllegalStateException naming your description on 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 registered AutomatorIdlingResources 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. With spectre-recording present, samples use the same window-scoped native still path as screenshot(windowIndex) (not a screen-region crop). Heavier than waitForIdle. 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: ScreenCaptureKitScreenshotter via spectre-recording-macos.
  • Windows: WindowsWindowScreenshotter for stills and WindowsGraphicsCaptureRecorder for window/region video via the framework-dependent Windows Graphics Capture helper packaged in spectre-recording-windows for 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 recordingreferences/recording.mdAutoScreenshotter, AutoRecorder, platform helper artifacts (spectre-recording-macos / -linux / -windows), region vs window targeting, frame-drop and HiDPI traps.
  • JUnit 4 vs JUnit 5 integrationreferences/junit.mdComposeAutomatorExtension, ComposeAutomatorRule, parameter resolution, lifecycle.
  • Experimental desktop input coordinationreferences/input-coordination.md — real-input contention across JVMs, InputLeasePolicy, JUnit InputIsolationConfig, runtime dependency, exact revoke, and unsafe forced recovery.
  • IntelliJ/Jewel-hosted Composereferences/intellij.md — running the automator from an AnAction against the IDE frame, the pooled-thread requirement, and the synthetic driver. If the work also involves Jewel popups, ComposePanel embedding, or SwingBridgeTheme, the repo-local jewel-swing-interop skill applies as well.
  • Java-agent attachreferences/agent.mdAgentAttach, AttachedAutomator, AttachOptions, the spectre-agent / spectre-agent-runtime split, runtime jar auto-discovery, custom attach paths, and inject when the target has Compose but no preinstalled spectre-core.

Common pitfalls (memorise these)

SymptomCauseFix
findOneBy… returns null right after an interactionSelectors don't waitAdd waitForNode(...) or waitForVisualIdle() first
Test deadlocks or throws inside any waitFor… / waitUntil…Called from the AWT EDTWrap in withContext(Dispatchers.Default) { … } — applies to all five waits, including waitForNode, waitUntilGone, and waitUntil
longClick/swipe/typeText complete instantly and missTest body uses runTestSwitch to runSpectreTest
Two parallel test JVMs steal focus from each otherBoth opted into real RobotDriver()Stay on the default RobotDriver.synthetic() (or pin rootWindow)
Parallel JVMs must preserve real OS input behaviorReal focus/pointer/clipboard state is shared across processesOpt 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 driverSynthetic events bypass HIDUse real RobotDriver() for those tests
Screenshot is blurry / mid-animationCaptured before frame stabilisedwaitForVisualIdle() first
Windows WGC helper missing or fails to startspectre-recording-windows is not on the runtime classpath, the helper was not built/staged locally, or .NET / Windows App Runtime is missingAdd 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=trueUsually stale Spectre or wrong rootWindow; current synthetic input should target the Compose key-listening host even without AWT focusVerify 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=trueUI-element/helper mode breaks clipboard-backed paste, even with synthetic inputDisable apple.awt.UIElement=true for the JVM hosting the test window, or use typeText for supported ASCII
pasteText times out on macOS in CIClipboard manager rewriting NSPasteboardDisable clipboard utilities in CI
Recording misses popups that escape the host windowPopups live in their own AWT window outside both the region rectangle and a window-targeted captureChoose 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 IllegalStateExceptionxprop missing or non-GNOME compositorUse AutoRecorder.startRegion(...) with an explicit rectangle
Coordinates derived from boundsInWindow land off-target on HiDPIboundsInWindow is Compose pixels (already density-scaled) and window-relative; AWT/Robot and recording regions use screen pixelsUse 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 --force without its unsafeTakeover=true overlap 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