[JEWEL-1390] Add Spectre and the standalone headful test lane

Declare Spectre in jewel_deps.MODULE.bazel rather than in lib, and expose it through the
testonly //platform/jewel:spectre target. Bazel then refuses to let any production target
reach it, so it cannot end up in the IDE or in a published artifact.

Add a spectre_test macro that runs the JUnit Platform console launcher directly. jps_test is
unusable here twice over: it forces -Djava.awt.headless=true, and it puts the IntelliJ test
runtime on the classpath, which would hide exactly the standalone-runtime leaks these tests
exist to catch. StandaloneRuntimeSmokeTest now asserts that property instead of leaving it to
a reviewer's word: com.intellij classes are barred from the lane apart from the icons API,
which standalone Jewel depends on deliberately.

Spectre targets are ordinary test targets, tagged requires-display: they run under
`bazel test //platform/jewel/...` on any machine with a display, and a lane that has none opts
out with --test_tag_filters=-requires-display. Two CI jobs run them, on Linux under Xvfb and on
Windows, both selecting by tag so new targets join without a workflow change. The internal
TeamCity/Patronus lanes should route on the same requires-display tag; nothing in this
repository drives that config. Windows is not redundant: JDialogRenderer renders popups through
RenderSettings.SwingGraphics there rather than Compose's default, so it is a different code
path.

Three things that are easy to get wrong, and were, each caught only by running on a real Linux
or Windows machine:

- Bazel scrubs the environment of test actions, so DISPLAY and XAUTHORITY have to be inherited
  explicitly. Without that the JVM falls back to :0.0 and every test dies in XToolkit's static
  initialiser under Xvfb, while passing on macOS.
- Discovery has to be by package, which JUnit resolves through the class loader. A bare
  --scan-classpath scans only classpath directories and every entry here is a jar, while
  --scan-classpath=<jar path> assumes a runfiles symlink tree and finds nothing on Windows,
  where Bazel uses a runfiles manifest. --fail-if-no-tests turns either mistake into a red
  lane rather than a green one that ran nothing.
- The X11 package list is what libawt_xawt and libskiko actually link against, plus libxrandr2
  and libxinerama1, which they dlopen and which therefore no linker error would ever flag.

The JEWEL-1396 popup tests are the first users of the lane.

closes https://github.com/JetBrains/intellij-community/pull/3618


(cherry picked from commit df8e3747affaae3d6be8d6e78a4613d08f088020)

IJ-MR-220560

GitOrigin-RevId: 42b78cda5b2200cb275ae18ce1cc2b0615e9389e
This commit is contained in:
Sebastiano Poggi
2026-09-07 22:29:10 +00:00
committed by intellij-monorepo-bot
parent 5552a03bb3
commit c64d1cc386
8 changed files with 497 additions and 1 deletions
+75
View File
@@ -214,6 +214,81 @@ jobs:
fi
echo "Bazel build files look up-to-date."
spectre_tests:
name: Jewel Spectre tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v7.0.1
name: Check out repository
- name: Install Xvfb
# libxrandr2 and libxinerama1 are dlopened, so nothing reports them missing.
# language=bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb \
libxrender1 \
libxtst6 \
libxi6 \
libxrandr2 \
libxinerama1 \
libgl1 \
libfontconfig1
- name: Run the Spectre headful UI tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
./bazel.cmd test //platform/jewel/... \
--build_tests_only \
--test_tag_filters=requires-display \
--test_output=errors
- name: Upload Bazel test logs
uses: actions/upload-artifact@v7.0.1
if: failure()
with:
name: spectre-test-logs
path: out/bazel-testlogs/platform/jewel/**
retention-days: 7
if-no-files-found: ignore
spectre_tests_windows:
name: Jewel Spectre tests (Windows)
runs-on: windows-latest
defaults:
run:
working-directory: .
steps:
# Kotlin test data blows past MAX_PATH, so checkout fails with "Filename too long" without this.
- name: Enable long paths
run: git config --global core.longpaths true
- uses: actions/checkout@v7.0.1
name: Check out repository
- name: Run the Spectre headful UI tests
# Not redundant: JDialogRenderer uses RenderSettings.SwingGraphics on Windows.
run: >
./bazel.cmd test //platform/jewel/...
--build_tests_only
--test_tag_filters=requires-display
--test_output=errors
- name: Upload Bazel test logs
uses: actions/upload-artifact@v7.0.1
if: failure()
with:
name: spectre-test-logs-windows
path: out/bazel-testlogs/platform/jewel/**
retention-days: 7
if-no-files-found: ignore
metalava:
name: Check for breaking API changes with Metalava
runs-on: ubuntu-latest
+102
View File
@@ -0,0 +1,102 @@
load("@rules_java//java:defs.bzl", "java_library")
load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_import")
filegroup(
name = "jewel_build_metadata",
srcs = [
"JEWEL_MARKER",
"gradle.properties",
"gradle/libs.versions.toml",
],
visibility = ["//visibility:public"],
)
# Spectre (JEWEL-1390). `testonly` is what keeps it out of the IDE and published artifacts.
kt_jvm_import(
name = "spectre-core",
testonly = True,
jar = "@jewel_deps_spectre_core//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "spectre-testing",
testonly = True,
jar = "@jewel_deps_spectre_testing//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "spectre-agent",
testonly = True,
jar = "@jewel_deps_spectre_agent//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "spectre-recording",
testonly = True,
jar = "@jewel_deps_spectre_recording//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "androidx-tracing-wire-desktop",
testonly = True,
jar = "@jewel_deps_androidx_tracing_wire_desktop//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "kotlinx-coroutines-swing",
testonly = True,
jar = "@jewel_deps_kotlinx_coroutines_swing//file",
visibility = ["//visibility:private"],
)
# Compose, coroutines and kotlin-stdlib are deliberately not exported: the tested module
# brings them, and a second copy would shadow the monorepo's versions.
java_library(
name = "spectre",
testonly = True,
visibility = ["//visibility:public"],
exports = [
":spectre-core",
":spectre-testing",
],
runtime_deps = [
":androidx-tracing-wire-desktop",
":kotlinx-coroutines-swing",
":spectre-agent",
":spectre-recording",
],
)
kt_jvm_import(
name = "junit-platform-console",
testonly = True,
jar = "@jewel_deps_junit_platform_console//file",
visibility = ["//visibility:private"],
)
kt_jvm_import(
name = "junit-platform-reporting",
testonly = True,
jar = "@jewel_deps_junit_platform_reporting//file",
visibility = ["//visibility:private"],
)
# Only the console front end is Jewel-local; the JUnit Platform itself comes from //libraries.
java_library(
name = "spectre-junit-console",
testonly = True,
visibility = ["//visibility:public"],
runtime_deps = [
":junit-platform-console",
":junit-platform-reporting",
"//libraries/junit5",
"//libraries/junit5-jupiter",
"//libraries/junit5-launcher",
],
)
+70
View File
@@ -61,6 +61,76 @@ diff --git a/platform/testFramework/monorepo/BUILD.bazel b/platform/testFramewor
But... Please double-check the dumps it changed before pushing.
## Running the Spectre headful UI tests
[Spectre](https://spectre.sebastiano.dev) drives a real Compose Desktop window, so its tests need a display and a
non-headless JVM. That rules out `jps_test`, which forces `-Djava.awt.headless=true` and puts the IntelliJ test
runtime on the classpath — the latter would hide exactly the standalone-runtime leaks these tests exist to catch.
The lane therefore uses the `spectre_test` macro in [spectre.bzl](../spectre.bzl), which runs the JUnit Platform
console launcher on a classpath containing only the module under test, Compose, and Spectre.
These are ordinary test targets. On any machine with a display they run as part of `bazel test //platform/jewel/...`,
or on their own:
```bash
./bazel.cmd test //platform/jewel/int-ui/int-ui-standalone-tests:jewel-intUi-standalone-spectre-tests
```
Windows will open and close on your desktop while they run; synthetic input goes to the test window, so you can keep
working, but do not be surprised by the flicker.
Every Spectre target is tagged `requires-display`. That tag is both the CI routing hint and the escape hatch: a lane
that genuinely has no display skips them with `--test_tag_filters=-requires-display`.
### Scope: standalone only
This lane covers **standalone** Jewel, and cannot be extended to the IJP bridge. Spectre automates a Compose Desktop
window; inside the IDE a Jewel popup is a `JBPopup` hosting a `ComposePanel`, and the application under test is the
IDE itself. So `JBPopupRenderer` and the other bridge renderers currently have **no headful coverage at all** — only
the Compose UI unit tests in `ui-tests`. That gap is JEWEL-1397, which is IDE Starter / UI Driver work rather than
Spectre work. Do not try to add a bridge test to `src/spectreTest`; it cannot run there.
### Adding a Spectre test
Put the class under a `src/spectreTest/kotlin` source root, in a package under `org.jetbrains.jewel`, and you are
done — there is no list of test classes to keep in sync.
Discovery is by package (`--select-package`), which JUnit resolves through the class loader. Both obvious
alternatives are broken under Bazel, and each fails silently rather than loudly:
- a bare `--scan-classpath` scans only classpath *directories*, and under Bazel every classpath entry is a jar;
- `--scan-classpath=<jar path>` assumes a runfiles symlink tree, so it finds nothing on Windows, where Bazel uses a
runfiles manifest instead.
That is why the macro also passes `--fail-if-no-tests`: it turns any future variant of that mistake into a red lane
instead of a green one that ran nothing.
### CI
Spectre targets are meant to run on **both** CI systems, and neither needs a target list — both select by tag, so a
new `spectre_test` target anywhere under `platform/jewel` joins CI on its own:
```bash
bazel test //platform/jewel/... --build_tests_only --test_tag_filters=requires-display
```
**GitHub Actions** runs two jobs from [jewel-checks.yml](../../../.github/workflows/jewel-checks.yml): `Jewel Spectre
tests` on Linux under Xvfb, and `Jewel Spectre tests (Windows)`, which needs no display setup because Windows always
hands the process a window station.
**TeamCity / Patronus** should run them alongside the headful IJP tests, on display-capable agents. That
configuration lives internally and there is nothing in this repository that drives it — the command above is the
whole contract, and `requires-display` is the tag to route on. A headless agent that picks up `//platform/jewel/...`
must exclude them with `--test_tag_filters=-requires-display`, or every Spectre test will fail in `XToolkit`'s
static initialiser.
Windows is not redundant coverage: `JDialogRenderer` renders popups through `RenderSettings.SwingGraphics` there
rather than Compose's default, with a different transparency hack, so it is a genuinely different code path.
Spectre itself is declared in [jewel_deps.MODULE.bazel](../jewel_deps.MODULE.bazel) and exposed as the `testonly`
target `//platform/jewel:spectre`. Keep it that way: `testonly` is what makes Bazel refuse to let a production target
depend on it, which is how Spectre stays out of the IDE and out of published Jewel artifacts.
## Add Devkit to IDE Build
On JPS we had a handy run configuration that built the IDE with the devkit bundled. On Bazel, the `idea_community`
@@ -1,6 +1,45 @@
load("@community//build:tests-options.bzl", "jps_test")
load("@rules_jvm//:jvm.bzl", "jvm_library")
load("//build:compiler-options.bzl", "create_kotlinc_options")
load("//platform/jewel:spectre.bzl", "spectre_test")
# Headful Spectre UI tests (JEWEL-1390). Hand-written, and outside the JPS model on purpose:
# the closure must stay standalone; StandaloneRuntimeSmokeTest is what enforces that.
create_kotlinc_options(
name = "custom_jewel-intUi-standalone-spectre-tests",
opt_in = [
"androidx.compose.ui.ExperimentalComposeUiApi",
"androidx.compose.foundation.ExperimentalFoundationApi",
"org.jetbrains.jewel.foundation.ExperimentalJewelApi",
"org.jetbrains.jewel.foundation.InternalJewelApi",
],
x_context_parameters = True,
)
spectre_test(
name = "jewel-intUi-standalone-spectre-tests",
srcs = glob(
["src/spectreTest/kotlin/**/*.kt"],
allow_empty = True,
),
# The tests assert that JDialogRenderer, not the Compose popup, is the renderer under test.
jvm_flags = ["-Djewel.customPopupRender=true"],
kotlinc_opts = ":custom_jewel-intUi-standalone-spectre-tests",
module_name = "intellij.platform.jewel.intUi.standalone.spectreTests",
visibility = ["//visibility:public"],
# Icon resources only; adds no IntelliJ Platform classes to the closure.
runtime_deps = ["//platform/icons"],
deps = [
"//libraries/compose-foundation-desktop",
"//libraries/junit5",
"//libraries/kotlinx/coroutines/core",
"//platform/jewel/foundation",
"//platform/jewel/int-ui/int-ui-standalone:jewel-intUi-standalone",
"//platform/jewel/ui",
"@lib//:kotlin-stdlib",
"@lib//:kotlin-test",
],
)
### auto-generated section `build intellij.platform.jewel.intUi.standalone.tests` start
@@ -0,0 +1,49 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.jewel.intui.standalone
import java.io.File
import java.util.jar.JarFile
import kotlin.test.assertTrue
import org.junit.jupiter.api.Test
// Guards the property that makes this lane worth having, and the reason it is not a jps_test: that runner puts the
// IntelliJ test runtime on the classpath itself, so it could never assert this.
class StandaloneRuntimeSmokeTest {
@Test
fun `only the icons API is on the classpath from com dot intellij`() {
val offenders = classpathJars().map { it to it.unexpectedIntellijClassCount() }.filter { (_, n) -> n > 0 }
assertTrue(
offenders.isEmpty(),
"The IntelliJ Platform has leaked into the standalone lane via: " +
offenders.joinToString { (jar, n) -> "${jar.name} ($n classes)" },
)
}
@Test
fun `the IntelliJ Application class cannot be loaded`() {
val loaded = runCatching { Class.forName("com.intellij.openapi.application.Application") }.isSuccess
assertTrue(!loaded, "com.intellij.openapi.application.Application must not be reachable from this lane")
}
private fun classpathJars(): List<File> =
System.getProperty("java.class.path").split(File.pathSeparatorChar).map(::File).filter {
it.isFile && it.name.endsWith(".jar")
}
private fun File.unexpectedIntellijClassCount(): Int =
JarFile(this).use { jar ->
jar.entries().asSequence().count { entry ->
entry.name.startsWith("com/intellij/") &&
entry.name.endsWith(".class") &&
ALLOWED_PACKAGES.none { entry.name.startsWith(it) }
}
}
private companion object {
// Standalone Jewel depends on the icons API and its implementation on purpose (the jb-icons-* modules), so
// this is the one com.intellij package the lane is allowed to see. Anything else is a leak.
private val ALLOWED_PACKAGES = listOf("com/intellij/platform/icons/")
}
}
@@ -41,7 +41,9 @@ import org.jetbrains.jewel.ui.component.rememberSpeedSearchState
import org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBox
import org.junit.jupiter.api.Test
// Note: these tests run intentionally Gradle-only until JEWEL-1390
// Headful, and deliberately not a jps_test: the app under test must keep a standalone-only runtime closure,
// with no IntelliJ Platform classes on the classpath. Runs in CI on any agent with a display:
// bazel test //platform/jewel/int-ui/int-ui-standalone-tests:jewel-intUi-standalone-spectre-tests
class CustomPopupRendererSpectreTest {
@Test
fun `escape closes a hovered combo box`(): Unit = runSpectreTestWithCustomPopupRenderer {
+63
View File
@@ -20,3 +20,66 @@ http_file(
sha256 = "3afe89a11120303c73c9bdda3d8fe558dd9070a6937d27819ddc04b275381245",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/io/gitlab/arturbosch/detekt/detekt-cli/1.23.8/detekt-cli-1.23.8-all.jar",
)
# Spectre (JEWEL-1390). Declared here rather than in `lib/MODULE.bazel` so it cannot reach the
# IDE or a published artifact.
http_file(
name = "jewel_deps_spectre_core",
downloaded_file_path = "spectre-core-0.5.0.jar",
sha256 = "1a81c7a259f38646673e8ddc75477f067f13615bc0bbe54454fd3d32b2980bd9",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/dev/sebastiano/spectre/spectre-core/0.5.0/spectre-core-0.5.0.jar",
)
http_file(
name = "jewel_deps_spectre_testing",
downloaded_file_path = "spectre-testing-0.5.0.jar",
sha256 = "0856b8d2758ffb436910bde53cd35f947d9873ff37173787c796fc6a49f6ff94",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/dev/sebastiano/spectre/spectre-testing/0.5.0/spectre-testing-0.5.0.jar",
)
# Compile dependency of spectre-testing; unused by the in-process lane, but must resolve.
http_file(
name = "jewel_deps_spectre_agent",
downloaded_file_path = "spectre-agent-0.5.0.jar",
sha256 = "6a6e81b0986456ecb1bce0b79c31a2ece46e747666be284fe12cdc1cebb2fb3d",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/dev/sebastiano/spectre/spectre-agent/0.5.0/spectre-agent-0.5.0.jar",
)
# Runtime dependency of spectre-testing.
http_file(
name = "jewel_deps_spectre_recording",
downloaded_file_path = "spectre-recording-0.5.0.jar",
sha256 = "ab44895f231546d0209924caa4f429f9a2fd700f499f9d1b34dcb92a39431f80",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/dev/sebastiano/spectre/spectre-recording/0.5.0/spectre-recording-0.5.0.jar",
)
# Runtime dependency of spectre-core, not otherwise in the monorepo.
http_file(
name = "jewel_deps_androidx_tracing_wire_desktop",
downloaded_file_path = "tracing-wire-desktop-2.0.0-alpha06.jar",
sha256 = "c6cb73c39f1179673686ca4dc56152844e0ace52087243cfef60fee261ec1934",
url = "https://cache-redirector.jetbrains.com/dl.google.com/dl/android/maven2/androidx/tracing/tracing-wire-desktop/2.0.0-alpha06/tracing-wire-desktop-2.0.0-alpha06.jar",
)
# Runtime dependency of spectre-core.
http_file(
name = "jewel_deps_kotlinx_coroutines_swing",
downloaded_file_path = "kotlinx-coroutines-swing-1.10.2.jar",
sha256 = "02bdb85f20ec08004429abf6887e8d0e40f3ab637d7462f817f0f8abbe7d1eb5",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-swing/1.10.2/kotlinx-coroutines-swing-1.10.2.jar",
)
# Entry point of the headful lane. Version must match the JUnit Platform in `lib`.
http_file(
name = "jewel_deps_junit_platform_console",
downloaded_file_path = "junit-platform-console-1.14.4.jar",
sha256 = "8fba295a9aaced613062b7a8486758b71418e1545a3775de8df9ed76cc062383",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/org/junit/platform/junit-platform-console/1.14.4/junit-platform-console-1.14.4.jar",
)
http_file(
name = "jewel_deps_junit_platform_reporting",
downloaded_file_path = "junit-platform-reporting-1.14.4.jar",
sha256 = "4a1d32f0f09d29f046ca51a81b8bfbc0f829392892672ea2e2cd530cd9c4b212",
url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/org/junit/platform/junit-platform-reporting/1.14.4/junit-platform-reporting-1.14.4.jar",
)
+96
View File
@@ -0,0 +1,96 @@
"""Headful Compose Desktop UI tests driven by Spectre (JEWEL-1390).
Runs the JUnit Platform console launcher rather than `jps_test`, which forces headless mode and
pulls in the IntelliJ test runtime. See platform/jewel/docs/bazel-build-tips.md.
"""
load("@rules_java//java:defs.bzl", "java_test")
load("@rules_jvm//:jvm.bzl", "jvm_library")
SPECTRE_JVM_FLAGS = [
"-Djava.awt.headless=false",
# Keeps the test window out of the macOS Dock.
"-Dapple.awt.UIElement=true",
"-ea",
]
# Not `manual`: CI is meant to run these. `requires-display` is the opt-out handle.
SPECTRE_TAGS = [
"requires-display",
"no-sandbox",
"local",
"external",
]
def spectre_test(
name,
srcs,
module_name,
test_packages = ["org.jetbrains.jewel"],
deps = [],
runtime_deps = [],
kotlinc_opts = None,
jvm_flags = [],
tags = [],
size = "medium",
data = [],
visibility = None,
**kwargs):
"""Compiles and runs a set of headful Spectre UI tests.
Args:
name: Target name. The compiled test sources land in `<name>_lib`.
srcs: Kotlin sources of the tests.
module_name: Kotlin module name for the compiled test sources.
test_packages: Root packages to discover tests in. The default covers all of Jewel.
deps: Compile dependencies. `//platform/jewel:spectre` is always added.
runtime_deps: Extra runtime-only dependencies.
kotlinc_opts: Label of the `create_kotlinc_options` target to compile with.
jvm_flags: Extra JVM flags, appended after `SPECTRE_JVM_FLAGS`.
tags: Extra tags, appended after `SPECTRE_TAGS`.
size: Bazel test size. Headful tests start a real app, so the default is `medium`.
data: Runfiles needed by the tests.
visibility: Visibility of the test target.
**kwargs: Passed through to `java_test`.
"""
lib_name = name + "_lib"
jvm_library(
name = lib_name,
testonly = True,
srcs = srcs,
kotlinc_opts = kotlinc_opts,
module_name = module_name,
visibility = ["//visibility:private"],
deps = deps + ["//platform/jewel:spectre"],
runtime_deps = runtime_deps,
)
java_test(
name = name,
main_class = "org.junit.platform.console.ConsoleLauncher",
use_testrunner = False,
# Both `--scan-classpath` forms silently find nothing under Bazel: bare scans only
# directories, and `=<jar>` needs a runfiles symlink tree, which Windows lacks.
args = [
"execute",
"--fail-if-no-tests",
"--details=tree",
"--disable-ansi-colors",
] + ["--select-package=" + test_package for test_package in test_packages],
jvm_flags = SPECTRE_JVM_FLAGS + jvm_flags,
# Bazel scrubs the test environment; without these AWT falls back to `:0.0` and fails.
env_inherit = [
"DISPLAY",
"XAUTHORITY",
],
runtime_deps = [
":" + lib_name,
"//platform/jewel:spectre-junit-console",
],
tags = SPECTRE_TAGS + tags,
size = size,
data = data,
visibility = visibility,
**kwargs
)