diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt
index ba6cdabcb9ff..68cd7d188574 100644
--- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt
+++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt
@@ -21,6 +21,7 @@ import org.jetbrains.jewel.bridge.actionSystem.ComponentDataProviderBridge
import org.jetbrains.jewel.bridge.component.JBPopupRenderer
import org.jetbrains.jewel.bridge.theme.SwingBridgeTheme
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
+import org.jetbrains.jewel.foundation.InternalJewelApi
import org.jetbrains.jewel.foundation.LocalComponent as LocalComponentFoundation
import org.jetbrains.jewel.foundation.util.JewelLogger
import org.jetbrains.jewel.ui.component.LocalPopupRenderer
@@ -210,7 +211,9 @@ private fun createJewelComposePanel(
return jewelPanel
}
-internal class JewelComposePanelWrapper(private val focusOnClickInside: Boolean) : BorderLayoutPanel(), UiDataProvider {
+@ApiStatus.Internal
+@InternalJewelApi
+public class JewelComposePanelWrapper(private val focusOnClickInside: Boolean) : BorderLayoutPanel(), UiDataProvider {
internal var targetProvider: UiDataProvider? = null
private val listener = AWTEventListener { event ->
if (event !is MouseEvent || event.button == MouseEvent.NOBUTTON) return@AWTEventListener
@@ -219,7 +222,7 @@ internal class JewelComposePanelWrapper(private val focusOnClickInside: Boolean)
}
}
- val composePanel: ComposePanel = ComposePanel()
+ public val composePanel: ComposePanel = ComposePanel()
init {
super.addToCenter(composePanel)
diff --git a/plugins/performanceTesting/remote-driver/BUILD.bazel b/plugins/performanceTesting/remote-driver/BUILD.bazel
index 3a63df2529f5..1fc60b3f689e 100644
--- a/plugins/performanceTesting/remote-driver/BUILD.bazel
+++ b/plugins/performanceTesting/remote-driver/BUILD.bazel
@@ -33,6 +33,8 @@ jvm_library(
"//libraries/jcef",
"//platform/platform-impl:ide-impl",
"//platform/ui.jcef",
+ "//platform/compose",
+ "//platform/core-ui",
],
exports = [
"//platform/remote-driver/core:driver-impl",
diff --git a/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml b/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml
index eef02af69efb..5b41e8772af8 100644
--- a/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml
+++ b/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml
@@ -27,5 +27,7 @@
+
+
\ No newline at end of file
diff --git a/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml b/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml
index 035a6e7750c4..e9c6b06bcdd8 100644
--- a/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml
+++ b/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml
@@ -1,4 +1,8 @@
+
+
+
+
@@ -18,5 +22,6 @@
+
\ No newline at end of file
diff --git a/plugins/performanceTesting/remote-driver/src/com/jetbrains/performancePlugin/remotedriver/xpath/ComposeXpathDataModelExtension.kt b/plugins/performanceTesting/remote-driver/src/com/jetbrains/performancePlugin/remotedriver/xpath/ComposeXpathDataModelExtension.kt
new file mode 100644
index 000000000000..1ad0f9831622
--- /dev/null
+++ b/plugins/performanceTesting/remote-driver/src/com/jetbrains/performancePlugin/remotedriver/xpath/ComposeXpathDataModelExtension.kt
@@ -0,0 +1,192 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.performancePlugin.remotedriver.xpath
+
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.awt.ComposePanel
+import androidx.compose.ui.semantics.SemanticsNode
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import com.intellij.ui.scale.JBUIScale
+import com.jetbrains.performancePlugin.jmxDriver.InvokerService
+import org.jetbrains.jewel.bridge.JewelComposePanelWrapper
+import org.w3c.dom.Document
+import org.w3c.dom.Element
+import java.awt.Component
+import java.awt.Dimension
+import java.awt.Point
+import java.util.Collections
+
+/**
+ * A lightweight wrapper component that represents a Compose SemanticsNode.
+ * This allows the remote driver to treat each Compose UI element as a distinct component
+ * with its own bounds and location, even though they all belong to the same ComposePanel.
+ *
+ * Also we need to take into account scaling since compose returns physical pixels and AWT scaled.
+ */
+private class ComposeSemanticsNodeWrapper(
+ private val node: SemanticsNode,
+ private val panel: ComposePanel,
+) : Component() {
+ private val scale = JBUIScale.sysScale(panel)
+
+ override fun getLocationOnScreen(): Point {
+ val bounds = node.boundsInRoot
+ return Point(
+ (bounds.left / scale).toInt() + panel.locationOnScreen.x,
+ (bounds.top / scale).toInt() + panel.locationOnScreen.y
+ )
+ }
+
+ override fun getSize(): Dimension {
+ return node.boundsInWindow.size.let { Dimension((it.width / scale).toInt(), (it.height / scale).toInt()) }
+ }
+
+ override fun getX(): Int {
+ return (node.boundsInWindow.left / scale).toInt()
+ }
+
+ override fun getY(): Int {
+ return (node.boundsInWindow.top / scale).toInt()
+ }
+
+ override fun getWidth(): Int {
+ return (node.boundsInWindow.width / scale).toInt()
+ }
+
+ override fun getHeight(): Int {
+ return (node.boundsInWindow.height / scale).toInt()
+ }
+
+ override fun isShowing(): Boolean {
+ return panel.isShowing && (node.config.getOrNull(SemanticsProperties.Disabled) == null)
+ }
+
+ override fun isVisible(): Boolean {
+ return panel.isVisible && (node.config.getOrNull(SemanticsProperties.Disabled) == null)
+ }
+
+ override fun toString(): String {
+ val text = node.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text }
+ val role = node.config.getOrNull(SemanticsProperties.Role)
+ return "ComposeSemanticsNode(role=$role, text=$text)"
+ }
+}
+
+class ComposeXpathDataModelExtension : XpathDataModelExtension {
+ override val isRemDevExtension: Boolean = false
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ override fun postProcessElement(doc: Document, component: Component, element: Element, parentElement: Element) {
+ val composePanel = when (component) {
+ is JewelComposePanelWrapper -> component.composePanel
+ else -> return
+ }
+
+ // Clear the cache before processing to remove stale references from previous UI snapshots
+ // The UI hierarchy is rebuilt on each request, so old wrappers are no longer needed
+ wrapperCache.clear()
+
+ try {
+ val semanticsOwners = composePanel.semanticsOwners
+ semanticsOwners.forEach { owner ->
+ traverseComposeTree(doc, element, owner.rootSemanticsNode, composePanel)
+ }
+ }
+ catch (_: Exception) {
+ return
+ }
+ }
+
+ private fun traverseComposeTree(doc: Document, parentElement: Element, node: SemanticsNode, composePanel: ComposePanel) {
+ val composeElement = doc.createElement("div")
+ composeElement.setAttribute("class", "ComposeNode")
+ composeElement.setAttribute("javaclass", "androidx.compose.ui.semantics.SemanticsNode")
+
+ val wrapperComponent = ComposeSemanticsNodeWrapper(node, composePanel)
+ composeElement.setUserData("component", wrapperComponent, null)
+
+ val ref = InvokerService.getInstance().putReference(wrapperComponent)
+ wrapperCache.add(wrapperComponent)
+
+ composeElement.setAttribute("refId", ref.id)
+ composeElement.setAttribute("hashCode", ref.identityHashCode.toString())
+ composeElement.setAttribute("asString", ref.asString())
+
+ val rdTarget = InvokerService.getInstance().rdTarget
+ composeElement.setAttribute("rdTarget", rdTarget.toString())
+
+ val role = node.config.getOrNull(SemanticsProperties.Role)?.toString() ?: "ComposeNode"
+
+ val textContent = buildString {
+ node.config.getOrNull(SemanticsProperties.Text)?.forEach { text ->
+ if (isNotEmpty()) append(" || ")
+ append(text.text)
+ }
+ }
+
+ val textNodeContent = StringBuilder("$role. ")
+
+ if (textContent.isNotEmpty()) {
+ composeElement.setAttribute("text", textContent)
+ composeElement.setAttribute("visible_text", textContent)
+ textNodeContent.append("text: '$textContent'. ")
+ }
+
+ node.config.getOrNull(SemanticsProperties.EditableText)?.let { editableText ->
+ composeElement.setAttribute("editabletext", editableText.text)
+ if (textContent.isEmpty()) {
+ textNodeContent.append("editabletext: '${editableText.text}'. ")
+ }
+ }
+
+ composeElement.appendChild(doc.createTextNode(textNodeContent.toString()))
+
+ node.config.getOrNull(SemanticsProperties.ContentDescription)?.let { descriptions ->
+ val contentDesc = descriptions.joinToString(" ")
+ composeElement.setAttribute("contentdescription", contentDesc)
+ }
+
+ node.config.getOrNull(SemanticsProperties.Role)?.let { role ->
+ composeElement.setAttribute("role", role.toString())
+ }
+
+ node.config.getOrNull(SemanticsProperties.TestTag)?.let { testTag ->
+ composeElement.setAttribute("testtag", testTag)
+ }
+
+ node.config.getOrNull(SemanticsProperties.ToggleableState)?.let { state ->
+ composeElement.setAttribute("toggleablestate", state.toString())
+ }
+
+ node.config.getOrNull(SemanticsProperties.Selected)?.let { selected ->
+ composeElement.setAttribute("selected", selected.toString())
+ }
+
+ node.config.getOrNull(SemanticsProperties.Focused)?.let { focused ->
+ composeElement.setAttribute("focused", focused.toString())
+ }
+
+ node.config.getOrNull(SemanticsProperties.Disabled)?.let {
+ composeElement.setAttribute("disabled", "true")
+ }
+
+ node.config.getOrNull(SemanticsProperties.Heading)?.let {
+ composeElement.setAttribute("heading", "true")
+ }
+
+ node.config.getOrNull(SemanticsProperties.ProgressBarRangeInfo)?.let { progressInfo ->
+ composeElement.setAttribute("progresscurrent", progressInfo.current.toString())
+ composeElement.setAttribute("progressrange", "${progressInfo.range.start}-${progressInfo.range.endInclusive}")
+ }
+
+ parentElement.appendChild(composeElement)
+
+ node.children.forEach { child ->
+ traverseComposeTree(doc, composeElement, child, composePanel)
+ }
+ }
+}
+
+// Cache to maintain strong references to wrapper components
+// This prevents garbage collection of wrappers that are registered with InvokerService
+private val wrapperCache = Collections.synchronizedList(mutableListOf())
\ No newline at end of file