Skip to content

Cross-JVM access

When the UI you want to drive lives in a different JVM than the test process — most commonly a Compose Desktop app you've launched as a separate process — Spectre's server module gives you an HTTP transport. The hosting JVM mounts a Ktor route on top of an in-process ComposeAutomator; the test JVM talks to it through HttpComposeAutomator.

IntelliJ-hosted Compose has its own page

For driving Jewel-on-IntelliJ tool windows or any Compose surface hosted inside an IntelliJ plugin, the in-process pattern with intellij-ide-starter for the test side is the recommended path — it sidesteps the IDE's classloader isolation and uses JetBrains' own IPC for the test ↔ IDE bridge. See IntelliJ-hosted Compose. The HTTP transport on this page is aimed at the standalone-app case.

HTTP transport scope

The HTTP transport is a deliberate subset of the in-process automator: windows, nodes by tag, click, type-text, and screenshot. Advanced features that need live JVM objects (idling resources, withTracing) or stateful long-poll semantics (waitForVisualIdle) are in-process only. If you need them, run the test JVM in the same process as the UI.

Server side: mount the routes

In the hosting JVM, build an in-process automator and install Spectre's routes on a Ktor application. installSpectreRoutes is engine-agnostic — Spectre intentionally doesn't bundle a Ktor server engine, so add one yourself:

dependencies {
    // ...your existing Spectre + ktor-server-core comes via the server module
    implementation("io.ktor:ktor-server-netty:2.3.12") // or :ktor-server-cio, :ktor-server-jetty
}
import dev.sebastiano.spectre.core.ComposeAutomator
import dev.sebastiano.spectre.server.installSpectreRoutes
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty

val automator = ComposeAutomator.inProcess()

embeddedServer(Netty, port = 9274) {
    installSpectreRoutes(automator)
}.start(wait = false)

9274 is the default port the client uses (HttpComposeAutomator.DEFAULT_PORT) — installSpectreRoutes itself only mounts routes on whatever Ktor Application you give it, so the hosting engine picks the listener. Using 9274 on both sides keeps the defaults aligned; otherwise pass a matching port to both embeddedServer(...) and ComposeAutomator.http(...).

installSpectreRoutes mounts everything under /spectre by default; pass basePath = "/foo" if you need it elsewhere.

ContentNegotiation

The routes exchange JSON. If ContentNegotiation isn't already installed on the application, installSpectreRoutes installs it with the kotlinx JSON converter. If you have already installed it for your own routes, make sure your configuration includes a JSON converter — Ktor doesn't let plugins merge converters into an existing installation, so Spectre leaves yours alone.

Engine choice

Spectre doesn't pick an engine for you — you bring your own (Netty, CIO, Jetty, etc.) and configure it however you need. The example above uses Netty; a lighter test embedded server might use CIO.

Client side: drive it

In the test JVM, the canonical entry point is the ComposeAutomator.http(...) companion extension:

import dev.sebastiano.spectre.core.ComposeAutomator
import dev.sebastiano.spectre.server.http
import kotlinx.coroutines.runBlocking

ComposeAutomator.http(host = "localhost", port = 9274).use { remote ->
    runBlocking {
        val nodes = remote.findByTestTag("Submit")
        if (nodes.isNotEmpty()) {
            remote.click(nodes.first().key)
        }
    }
}

HttpComposeAutomator is AutoCloseable and owns its underlying Ktor HttpClient; use use { ... } (or call close() yourself) so the connection pool and selector threads are released.

What's on the wire

Everything is JSON, modelled by DTOs in dev.sebastiano.spectre.server.dto. Notable shapes:

DTO Role
WindowSummaryDto Per-window summary (index, surface id, bounds, popup flag).
NodeSnapshotDto Read-only projection of an AutomatorNode.
NodesResponse List wrapper around NodeSnapshotDto.
WindowsResponse List wrapper around WindowSummaryDto.
ClickRequest { "nodeKey": "surfaceId:ownerIndex:nodeId" }.
TypeTextRequest { "text": "..." }. Types into whatever has focus.
ScreenshotResponse Base64-encoded PNG bytes.

Node keys travel as the canonical string form surfaceId:ownerIndex:nodeId — the stable string form used by the transport contract and pinned by NodeKeyContractTest in the testing module.

Use cases

The server module pays for itself when:

  • The UI under test is an IntelliJ IDE or a third-party application whose run-loop you can't modify — install the routes inside a plugin and drive from a separate test process.
  • You're running multiple test JVMs in parallel against a long-lived UI host (e.g., a sample app launched once, hit by many tests). Combine with RobotDriver.synthetic on the server side so each test doesn't fight for global focus.
  • You want to separate test orchestration from rendering for performance reasons, e.g., to run the test JVM with aggressive coroutine debugging while the UI runs lean.

If your test owns the UI and runs in the same JVM, stick with ComposeAutomator.inProcess() — it's cheaper and exposes the full automator surface.