diff --git a/.github/workflows/jewel-checks.yml b/.github/workflows/jewel-checks.yml index c3128355ca1d..28c2beac8a8d 100644 --- a/.github/workflows/jewel-checks.yml +++ b/.github/workflows/jewel-checks.yml @@ -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 diff --git a/platform/jewel/BUILD.bazel b/platform/jewel/BUILD.bazel index e69de29bb2d1..83fec6a65b3d 100644 --- a/platform/jewel/BUILD.bazel +++ b/platform/jewel/BUILD.bazel @@ -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", + ], +) diff --git a/platform/jewel/docs/bazel-build-tips.md b/platform/jewel/docs/bazel-build-tips.md index 2cd301c09e7d..1e4f483d4680 100644 --- a/platform/jewel/docs/bazel-build-tips.md +++ b/platform/jewel/docs/bazel-build-tips.md @@ -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=` 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` diff --git a/platform/jewel/int-ui/int-ui-standalone-tests/BUILD.bazel b/platform/jewel/int-ui/int-ui-standalone-tests/BUILD.bazel index 0e8a5f2ca478..8f368da02cc0 100644 --- a/platform/jewel/int-ui/int-ui-standalone-tests/BUILD.bazel +++ b/platform/jewel/int-ui/int-ui-standalone-tests/BUILD.bazel @@ -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 diff --git a/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/StandaloneRuntimeSmokeTest.kt b/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/StandaloneRuntimeSmokeTest.kt new file mode 100644 index 000000000000..8c3b79f7a7f1 --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/StandaloneRuntimeSmokeTest.kt @@ -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 = + 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/") + } +} diff --git a/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/popup/CustomPopupRendererSpectreTest.kt b/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/popup/CustomPopupRendererSpectreTest.kt index 4a9e34e185c0..d37af476b23e 100644 --- a/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/popup/CustomPopupRendererSpectreTest.kt +++ b/platform/jewel/int-ui/int-ui-standalone-tests/src/spectreTest/kotlin/org/jetbrains/jewel/intui/standalone/popup/CustomPopupRendererSpectreTest.kt @@ -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 { diff --git a/platform/jewel/jewel_deps.MODULE.bazel b/platform/jewel/jewel_deps.MODULE.bazel index 1ca4ac195505..2cb82ada997a 100644 --- a/platform/jewel/jewel_deps.MODULE.bazel +++ b/platform/jewel/jewel_deps.MODULE.bazel @@ -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", +) diff --git a/platform/jewel/spectre.bzl b/platform/jewel/spectre.bzl new file mode 100644 index 000000000000..0d1122195cee --- /dev/null +++ b/platform/jewel/spectre.bzl @@ -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 `_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 `=` 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 + )