diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8ad4bec074..da65bab3aa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -390,7 +390,11 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git add VERSION **/pom.xml
+ # Stage the root pom.xml explicitly and the rest via a quoted glob so git's
+ # own pathspec (wildmatch) expands '**' to any depth. Unquoted, bash without
+ # globstar treats '**' as '*' and stages only one-directory-deep poms,
+ # leaving the root parent POM and nested module POMs stale.
+ git add VERSION pom.xml '**/pom.xml'
if git diff --cached --quiet; then
echo "::notice::No version changes to commit — version may already be bumped."
diff --git a/VERSION b/VERSION
index 166a2faac6..57d857da15 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.11.12-SNAPSHOT
+4.11.14-SNAPSHOT
diff --git a/docs/ql-functions-guide.md b/docs/ql-functions-guide.md
index 9604789e3d..94be8f9036 100644
--- a/docs/ql-functions-guide.md
+++ b/docs/ql-functions-guide.md
@@ -876,6 +876,13 @@ FROM DOM_LOAD_AND_SELECT('https://example.com', 'a[href]');
Automatically appends `img` / `a` to the CSS query if not present.
+PowerCSS selectors are fully supported in the query argument, including `:expr(...)` with spaces, e.g. select images wider than 200px:
+
+```sql
+SELECT DOM_ALL_IMGS(DOM, 'img:expr(width > 200)') AS wide_images FROM DOM_LOAD('...');
+SELECT DOM_FIRST_IMG(DOM, 'img:expr(width > 200 && height > 200)') AS hero_image FROM DOM_LOAD('...');
+```
+
```sql
-- DOM_ALL_IMGS: All image absolute src URLs
SELECT DOM_ALL_IMGS(DOM) AS images FROM DOM_LOAD('...');
diff --git a/pom.xml b/pom.xml
index b08e53b2f0..35ef0d58f0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,7 +21,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pom
Browser4 Base
diff --git a/pulsar-bom/pom.xml b/pulsar-bom/pom.xml
index ee71975c95..4f4334ca2b 100644
--- a/pulsar-bom/pom.xml
+++ b/pulsar-bom/pom.xml
@@ -16,7 +16,7 @@
ai.platon.pulsar
pulsar-bom
Pulsar BOM
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pom
diff --git a/pulsar-core/pom.xml b/pulsar-core/pom.xml
index 59536859b7..32b4948ec6 100644
--- a/pulsar-core/pom.xml
+++ b/pulsar-core/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-core
diff --git a/pulsar-core/pulsar-browser/pom.xml b/pulsar-core/pulsar-browser/pom.xml
index 81c074b6ce..7d9d4c5f36 100644
--- a/pulsar-core/pulsar-browser/pom.xml
+++ b/pulsar-core/pulsar-browser/pom.xml
@@ -4,7 +4,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotFiltering.kt b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotFiltering.kt
new file mode 100644
index 0000000000..a072f1dbca
--- /dev/null
+++ b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotFiltering.kt
@@ -0,0 +1,40 @@
+package ai.platon.pulsar.api.snapshot
+
+/**
+ * Shared interactive-only filtering contract for ARIA snapshot rendering.
+ *
+ * Both [AriaSnapshotRenderer] and [NanoAriaSnapshotRenderer] must agree on what
+ * "interactive" means for [ai.platon.pulsar.chrome.dom.model.AriaSnapshotOptions.interactive],
+ * otherwise the same DOM produces different interactive snapshots depending on which
+ * renderer runs (viewport-scoped snapshots use the nano renderer, whole-page snapshots
+ * use the full renderer).
+ *
+ * A node qualifies when it is an interactive widget by [INTERACTIVE_ROLES] or carries an
+ * interactability signal (`isInteractable` / `interactive` flag, computed from clickability,
+ * cursor:pointer style, native control tags and AX roles during snapshot collection).
+ *
+ * Addressability must NOT qualify a node: backendNodeId-based refs are assigned to
+ * virtually every DOM node, so a ref-based early return turned the interactive filter
+ * into a no-op (Browser4base issue #3). Renderers skip non-qualifying nodes and promote
+ * their children instead.
+ */
+object AriaSnapshotFiltering {
+ /** Roles of interactive widgets kept in interactive-only snapshots. */
+ val INTERACTIVE_ROLES = setOf(
+ "button", "link", "textbox", "checkbox", "combobox", "searchbox",
+ "spinbutton", "slider", "radio", "option", "listbox", "menuitem", "tab",
+ "switch", "treeitem", "menuitemcheckbox", "menuitemradio"
+ )
+
+ /**
+ * Decide whether a node qualifies for interactive-only snapshots.
+ *
+ * @param role The node's effective ARIA role (explicit or implicit).
+ * @param interactable The snapshot-level interactability flag: [ai.platon.pulsar.api.model.MergedDOMTreeNode.isInteractable]
+ * in the full renderer, [ai.platon.pulsar.api.model.NanoDOMTreeNode.interactive] in the nano renderer.
+ * Both carry the same underlying value computed during snapshot collection.
+ */
+ fun isInteractiveNode(role: String, interactable: Boolean?): Boolean {
+ return interactable == true || role in INTERACTIVE_ROLES
+ }
+}
diff --git a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotRenderer.kt b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotRenderer.kt
index 3616b1d1c9..55951ac0a4 100644
--- a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotRenderer.kt
+++ b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotRenderer.kt
@@ -42,13 +42,18 @@ object AriaSnapshotRenderer {
val props = renderProps(node, role, accessibleName, options)
val ref = original.backendNodeId.takeIf { it != null && it > 0 }?.let { "e$it" }
- // --interactive: skip non-interactive nodes, promote their children
- if (options.interactive && !isInteractiveNode(node, role, props, ref)) {
+ // --interactive: skip non-interactive nodes, promote their children.
+ // Addressability (ref/backendNodeId) is deliberately not a qualifying signal,
+ // see AriaSnapshotFiltering for the shared contract.
+ if (options.interactive && !AriaSnapshotFiltering.isInteractiveNode(role, node.originalNode.isInteractable)) {
return children
}
- // --compact: skip generic/group/paragraph nodes that carry no semantic info
- if (options.compact && shouldCompactNode(role, accessibleName, props, children)) {
+ // --compact: skip generic/group/paragraph nodes that carry no semantic info.
+ // When --interactive is active the filter already removed structural noise, so
+ // compacting a kept (interactive) node would drop genuine click targets
+ // (e.g. a nameless cursor:pointer div).
+ if (!options.interactive && options.compact && shouldCompactNode(role, accessibleName, props, children)) {
return children
}
@@ -141,18 +146,6 @@ object AriaSnapshotRenderer {
return props
}
- private fun isInteractiveNode(
- node: OptimizedDOMTreeNode,
- role: String,
- props: LinkedHashMap,
- ref: String?
- ): Boolean {
- if (ref != null) return true
- if (node.interactiveIndex != null) return true
- if (node.originalNode.isInteractable == true) return true
- return role in INTERACTIVE_ROLES
- }
-
private fun shouldCompactNode(
role: String,
accessibleName: String?,
@@ -312,10 +305,4 @@ object AriaSnapshotRenderer {
val nodeName = node.nodeName.trim().lowercase(Locale.ROOT)
return nodeName == "#text" || nodeName == "text"
}
-
- private val INTERACTIVE_ROLES = setOf(
- "button", "link", "textbox", "checkbox", "combobox", "searchbox",
- "spinbutton", "slider", "radio", "option", "listbox", "menuitem", "tab",
- "switch", "treeitem", "menuitemcheckbox", "menuitemradio"
- )
}
diff --git a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/NanoAriaSnapshotRenderer.kt b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/NanoAriaSnapshotRenderer.kt
index 69b0d18af6..a1c64fd9e8 100644
--- a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/NanoAriaSnapshotRenderer.kt
+++ b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/NanoAriaSnapshotRenderer.kt
@@ -63,13 +63,17 @@ object NanoAriaSnapshotRenderer {
val role = role(node, attrs) ?: return children
val props = renderProps(attrs, role, accessibleName, options)
- // --interactive: skip non-interactive nodes, promote their children
- if (options.interactive && !isInteractiveNode(node, role, props)) {
+ // --interactive: skip non-interactive nodes, promote their children.
+ // Addressability (locator-based ref) is deliberately not a qualifying signal,
+ // see AriaSnapshotFiltering for the shared contract.
+ if (options.interactive && !AriaSnapshotFiltering.isInteractiveNode(role, node.interactive)) {
return children
}
- // --compact: skip generic/group/paragraph nodes that carry no semantic info
- if (options.compact && shouldCompact(node, role, accessibleName, props, children)) {
+ // --compact: skip generic/group/paragraph nodes that carry no semantic info.
+ // When --interactive is active the filter already removed structural noise, so
+ // compacting a kept (interactive) node would drop genuine click targets.
+ if (!options.interactive && options.compact && shouldCompact(node, role, accessibleName, props, children)) {
return children
}
@@ -143,16 +147,6 @@ object NanoAriaSnapshotRenderer {
return props
}
- private fun isInteractiveNode(
- node: NanoDOMTreeNode,
- role: String,
- props: LinkedHashMap
- ): Boolean {
- if (node.ref > 0) return true
- if (node.interactive == true) return true
- return role in INTERACTIVE_ROLES
- }
-
private fun shouldCompact(
node: NanoDOMTreeNode,
role: String,
@@ -293,10 +287,4 @@ object NanoAriaSnapshotRenderer {
private fun stringAttributes(node: NanoDOMTreeNode): Map {
return node.attributes.orEmpty().mapValues { (_, value) -> value.toString() }
}
-
- private val INTERACTIVE_ROLES = setOf(
- "button", "link", "textbox", "checkbox", "combobox", "searchbox",
- "spinbutton", "slider", "radio", "option", "listbox", "menuitem", "tab",
- "switch", "treeitem", "menuitemcheckbox", "menuitemradio"
- )
}
diff --git a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/chrome/dom/model/AriaSnapshotOptions.kt b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/chrome/dom/model/AriaSnapshotOptions.kt
index 2eb751ebc7..0dc712fe34 100644
--- a/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/chrome/dom/model/AriaSnapshotOptions.kt
+++ b/pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/chrome/dom/model/AriaSnapshotOptions.kt
@@ -7,7 +7,17 @@ package ai.platon.pulsar.chrome.dom.model
* they do not affect CDP-level data collection (see [ai.platon.pulsar.api.model.SnapshotOptions] for that).
*/
data class AriaSnapshotOptions(
- /** Only include interactive elements (buttons, links, inputs, etc.). */
+ /**
+ * Only include interactive elements (buttons, links, inputs, etc.).
+ *
+ * A node qualifies when its role is an interactive widget or it carries an
+ * interactability signal (clickability, cursor:pointer, native control or AX-role
+ * heuristics computed during snapshot collection). Addressability alone — a
+ * backendNodeId-based `ref` — does NOT qualify a node, since backend node ids are
+ * assigned to virtually every DOM node. Non-qualifying nodes are skipped and their
+ * interactive descendants are promoted instead. Both renderers
+ * (viewport/nano and whole-page/full) share the same predicate.
+ */
val interactive: Boolean = false,
/** Always include href URLs for link elements (prevent URL-collapse). */
val urls: Boolean = false,
diff --git a/pulsar-core/pulsar-browser/src/test/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotInteractiveTest.kt b/pulsar-core/pulsar-browser/src/test/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotInteractiveTest.kt
new file mode 100644
index 0000000000..146f572027
--- /dev/null
+++ b/pulsar-core/pulsar-browser/src/test/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotInteractiveTest.kt
@@ -0,0 +1,480 @@
+package ai.platon.pulsar.api.snapshot
+
+import ai.platon.pulsar.api.model.MergedDOMTreeNode
+import ai.platon.pulsar.api.model.NanoDOMTreeNode
+import ai.platon.pulsar.api.model.NodeType
+import ai.platon.pulsar.api.model.OptimizedDOMTreeNode
+import ai.platon.pulsar.chrome.dom.model.AriaSnapshotOptions
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+/**
+ * Regression tests for Browser4base issue #3: the `interactive` filter must not
+ * treat addressability (a backendNodeId/locator-based `ref`) as an interactivity
+ * signal, and both renderers must share the same predicate.
+ */
+class AriaSnapshotInteractiveTest {
+
+ private fun interactiveOptions() = AriaSnapshotOptions(interactive = true, boxes = false)
+ private fun fullOptions() = AriaSnapshotOptions(interactive = false, compact = false, boxes = false)
+
+ // ------------------------------------------------------------------ helpers
+
+ private fun rolesOf(snapshot: String): List {
+ return snapshot.lineSequence()
+ .map { it.trimStart() }
+ .filter { it.startsWith("- ") }
+ .mapNotNull { line ->
+ val key = line.removePrefix("- ").trim()
+ val role = key.substringBefore(' ').substringBefore(':').trim()
+ role.takeIf { it.isNotEmpty() && !it.startsWith("/") }
+ }
+ .toList()
+ }
+
+ private val structuralRoles = setOf(
+ "heading", "paragraph", "article", "listitem", "section", "region", "generic",
+ "list", "banner", "contentinfo", "complementary", "group", "table", "main", "navigation"
+ )
+
+ private fun assertNoStructuralRoles(snapshot: String) {
+ val roles = rolesOf(snapshot).filter { it != "text" }.toSet()
+ val leaked = roles.intersect(structuralRoles)
+ assertTrue(
+ leaked.isEmpty(),
+ "Interactive snapshot must not contain structural roles $leaked | snapshot:\n$snapshot"
+ )
+ }
+
+ private fun ariaText(value: String): MergedDOMTreeNode = MergedDOMTreeNode(
+ nodeName = "#text",
+ nodeValue = value,
+ nodeType = NodeType.TEXT_NODE
+ )
+
+ private fun ariaElement(
+ tag: String,
+ attrs: Map = emptyMap(),
+ backendNodeId: Int? = null,
+ isInteractable: Boolean? = null,
+ text: String? = null,
+ children: List = emptyList()
+ ): OptimizedDOMTreeNode {
+ val textNodes = text?.let { listOf(ariaText(it)) } ?: emptyList()
+ val original = MergedDOMTreeNode(
+ nodeId = backendNodeId ?: 0,
+ backendNodeId = backendNodeId,
+ nodeName = tag,
+ nodeValue = "",
+ attributes = attrs,
+ isInteractable = isInteractable,
+ children = textNodes
+ )
+ return OptimizedDOMTreeNode(originalNode = original, children = children)
+ }
+
+ private fun nanoText(value: String): NanoDOMTreeNode = NanoDOMTreeNode(
+ nodeName = "#text",
+ nodeValue = value
+ )
+
+ private fun nanoElement(
+ tag: String,
+ attrs: Map = emptyMap(),
+ refId: Int = 0,
+ interactive: Boolean? = null,
+ text: String? = null,
+ children: List = emptyList()
+ ): NanoDOMTreeNode {
+ val textNodes = text?.let { listOf(nanoText(it)) } ?: emptyList()
+ return NanoDOMTreeNode(
+ locator = if (refId > 0) "0,$refId" else null,
+ nodeName = tag,
+ nodeValue = "",
+ attributes = attrs,
+ interactive = interactive,
+ children = textNodes + children
+ )
+ }
+
+ /**
+ * A mixed page fragment: structural noise plus interactive controls, all carrying
+ * backend node ids / locators (as real CDP trees do).
+ */
+ private fun ariaMixedTree(): OptimizedDOMTreeNode {
+ return ariaElement(
+ "div",
+ backendNodeId = 1,
+ children = listOf(
+ ariaElement("h1", backendNodeId = 2, text = "Title"),
+ ariaElement(
+ "p",
+ attrs = mapOf("role" to "paragraph", "aria-label" to "Body"),
+ backendNodeId = 3,
+ text = "Body"
+ ),
+ ariaElement("li", backendNodeId = 4, text = "Item"),
+ ariaElement("article", backendNodeId = 5, text = "Story"),
+ ariaElement(
+ "ul",
+ backendNodeId = 6,
+ children = listOf(ariaElement("li", backendNodeId = 7, text = "Entry"))
+ ),
+ ariaElement("button", backendNodeId = 8, text = "Go"),
+ ariaElement(
+ "a",
+ attrs = mapOf("href" to "https://example.com", "aria-label" to "Docs"),
+ backendNodeId = 9,
+ text = "Docs"
+ ),
+ ariaElement(
+ "input",
+ attrs = mapOf("type" to "checkbox", "aria-label" to "Accept"),
+ backendNodeId = 10
+ )
+ )
+ )
+ }
+
+ private fun nanoMixedTree(): NanoDOMTreeNode {
+ return nanoElement(
+ "div",
+ refId = 1,
+ children = listOf(
+ nanoElement("h1", refId = 2, text = "Title"),
+ nanoElement("p", attrs = mapOf("role" to "paragraph"), refId = 3, text = "Body"),
+ nanoElement("li", refId = 4, text = "Item"),
+ nanoElement("article", refId = 5, text = "Story"),
+ nanoElement(
+ "ul",
+ refId = 6,
+ children = listOf(nanoElement("li", refId = 7, text = "Entry"))
+ ),
+ nanoElement("button", refId = 8, text = "Go"),
+ nanoElement(
+ "a",
+ attrs = mapOf("href" to "https://example.com"),
+ refId = 9,
+ text = "Docs"
+ ),
+ nanoElement(
+ "input",
+ attrs = mapOf("type" to "checkbox", "aria-label" to "Accept"),
+ refId = 10
+ )
+ )
+ )
+ }
+
+ // ------------------------------------------------------- shared contract
+
+ @Test
+ @DisplayName("shared predicate: role widgets and interactable flags qualify, structural roles and refs do not")
+ fun sharedPredicateQualifiesRoleWidgetsAndInteractableFlagsOnly() {
+ assertTrue(AriaSnapshotFiltering.isInteractiveNode("button", null))
+ assertTrue(AriaSnapshotFiltering.isInteractiveNode("textbox", null))
+ assertTrue(AriaSnapshotFiltering.isInteractiveNode("generic", true))
+ assertFalse(AriaSnapshotFiltering.isInteractiveNode("generic", null))
+ assertFalse(AriaSnapshotFiltering.isInteractiveNode("heading", null))
+ assertFalse(AriaSnapshotFiltering.isInteractiveNode("paragraph", null))
+ assertFalse(AriaSnapshotFiltering.isInteractiveNode("listitem", null))
+ assertFalse(AriaSnapshotFiltering.isInteractiveNode("article", null))
+ assertFalse(AriaSnapshotFiltering.isInteractiveNode("region", null))
+ }
+
+ // ------------------------------------------------------------ full renderer
+
+ @Test
+ @DisplayName("full renderer: interactive mode filters structural nodes even when refs are present")
+ fun fullRendererInteractiveModeFiltersStructuralNodesEvenWithRefs() {
+ val snapshot = AriaSnapshotRenderer.render(ariaMixedTree(), interactiveOptions())
+
+ assertTrue(snapshot.contains("button \"Go\" [ref=e8]"), "Button should be kept: $snapshot")
+ assertTrue(snapshot.contains("link \"Docs\" [ref=e9]"), "Link should be kept: $snapshot")
+ assertTrue(snapshot.contains("- /url: https://example.com"), "Link URL should be kept: $snapshot")
+ assertTrue(snapshot.contains("checkbox \"Accept\" [ref=e10]"), "Checkbox should be kept: $snapshot")
+ assertNoStructuralRoles(snapshot)
+ }
+
+ @Test
+ @DisplayName("full renderer: non-interactive mode keeps structural nodes (filter is interactive-only)")
+ fun fullRendererNonInteractiveModeKeepsStructuralNodes() {
+ val snapshot = AriaSnapshotRenderer.render(ariaMixedTree(), fullOptions())
+
+ assertTrue(snapshot.contains("heading \"Title\" [ref=e2]"), "Heading should be kept: $snapshot")
+ assertTrue(snapshot.contains("paragraph \"Body\" [ref=e3]"), "Paragraph should be kept: $snapshot")
+ assertTrue(snapshot.contains("listitem \"Item\" [ref=e4]"), "Listitem should be kept: $snapshot")
+ assertTrue(snapshot.contains("article \"Story\" [ref=e5]"), "Article should be kept: $snapshot")
+ assertTrue(snapshot.contains("button \"Go\" [ref=e8]"), "Button should be kept: $snapshot")
+ }
+
+ @Test
+ @DisplayName("full renderer: a plain div with a backendNodeId must not survive interactive mode")
+ fun fullRendererPlainDivWithBackendNodeIdDoesNotSurviveInteractiveMode() {
+ val root = ariaElement("div", backendNodeId = 55, text = "Plain container")
+
+ val interactive = AriaSnapshotRenderer.render(root, interactiveOptions())
+ assertTrue(interactive.isBlank(), "Nothing should survive: $interactive")
+
+ val full = AriaSnapshotRenderer.render(root, fullOptions())
+ assertTrue(full.contains("generic \"Plain container\" [ref=e55]"), "Default mode keeps the div: $full")
+ }
+
+ @Test
+ @DisplayName("full renderer: non-interactive container is dropped, interactive descendants are promoted")
+ fun fullRendererPromotesInteractiveDescendantsOfSkippedContainers() {
+ val root = ariaElement(
+ "div",
+ backendNodeId = 1,
+ children = listOf(
+ ariaElement(
+ "div",
+ backendNodeId = 2,
+ children = listOf(
+ ariaElement("button", backendNodeId = 3, text = "Go")
+ )
+ )
+ )
+ )
+
+ val snapshot = AriaSnapshotRenderer.render(root, interactiveOptions())
+
+ assertTrue(snapshot.contains("button \"Go\" [ref=e3]"), "Button should be promoted: $snapshot")
+ assertNoStructuralRoles(snapshot)
+ }
+
+ @Test
+ @DisplayName("full renderer: interactable generic (cursor:pointer) survives interactive and compact mode")
+ fun fullRendererKeepsInteractableGenericNode() {
+ val root = ariaElement(
+ "div",
+ backendNodeId = 1,
+ children = listOf(
+ ariaElement("div", backendNodeId = 2, isInteractable = true, text = "Clickable area")
+ )
+ )
+
+ // Default options: interactive = true, compact = true. Compact must not swallow
+ // a node the interactive filter kept.
+ val snapshot = AriaSnapshotRenderer.render(
+ root,
+ AriaSnapshotOptions(interactive = true, boxes = false)
+ )
+
+ assertTrue(
+ snapshot.contains("generic \"Clickable area\" [ref=e2] [cursor=pointer]"),
+ "Interactable generic should be kept with cursor marker: $snapshot"
+ )
+ }
+
+ @Test
+ @DisplayName("full renderer: input type matrix maps to interactive widget roles")
+ fun fullRendererInputTypeMatrix() {
+ val types = mapOf(
+ "text" to "textbox",
+ "checkbox" to "checkbox",
+ "radio" to "radio",
+ "search" to "searchbox",
+ "range" to "slider",
+ "number" to "spinbutton",
+ "submit" to "button"
+ )
+ val root = ariaElement(
+ "div",
+ backendNodeId = 1,
+ children = types.keys.mapIndexed { index, type ->
+ ariaElement(
+ "input",
+ attrs = mapOf("type" to type, "aria-label" to "Field $type"),
+ backendNodeId = index + 2
+ )
+ }
+ )
+
+ val snapshot = AriaSnapshotRenderer.render(root, interactiveOptions())
+ val roles = rolesOf(snapshot).toSet()
+
+ types.values.forEach { expectedRole ->
+ assertTrue(roles.contains(expectedRole), "Expected role $expectedRole in: $snapshot")
+ }
+ assertNoStructuralRoles(snapshot)
+ }
+
+ // ------------------------------------------------------------ nano renderer
+
+ @Test
+ @DisplayName("nano renderer: interactive mode filters structural nodes even when locator refs are present")
+ fun nanoRendererInteractiveModeFiltersStructuralNodesEvenWithRefs() {
+ val snapshot = NanoAriaSnapshotRenderer.render(nanoMixedTree(), interactiveOptions())
+
+ assertTrue(snapshot.contains("button \"Go\" [ref=e8]"), "Button should be kept: $snapshot")
+ assertTrue(snapshot.contains("link \"Docs\" [ref=e9]"), "Link should be kept: $snapshot")
+ assertTrue(snapshot.contains("- /url: https://example.com"), "Link URL should be kept: $snapshot")
+ assertTrue(snapshot.contains("checkbox \"Accept\" [ref=e10]"), "Checkbox should be kept: $snapshot")
+ assertNoStructuralRoles(snapshot)
+ }
+
+ @Test
+ @DisplayName("nano renderer: non-interactive mode keeps structural nodes (filter is interactive-only)")
+ fun nanoRendererNonInteractiveModeKeepsStructuralNodes() {
+ val snapshot = NanoAriaSnapshotRenderer.render(nanoMixedTree(), fullOptions())
+
+ assertTrue(snapshot.contains("heading \"Title\" [ref=e2]"), "Heading should be kept: $snapshot")
+ assertTrue(snapshot.contains("paragraph \"Body\" [ref=e3]"), "Paragraph should be kept: $snapshot")
+ assertTrue(snapshot.contains("listitem \"Item\" [ref=e4]"), "Listitem should be kept: $snapshot")
+ assertTrue(snapshot.contains("article \"Story\" [ref=e5]"), "Article should be kept: $snapshot")
+ assertTrue(snapshot.contains("button \"Go\" [ref=e8]"), "Button should be kept: $snapshot")
+ }
+
+ @Test
+ @DisplayName("nano renderer: a plain div with a locator ref must not survive interactive mode")
+ fun nanoRendererPlainDivWithLocatorRefDoesNotSurviveInteractiveMode() {
+ val root = nanoElement("div", refId = 55, text = "Plain container")
+
+ val interactive = NanoAriaSnapshotRenderer.render(root, interactiveOptions())
+ assertTrue(interactive.isBlank(), "Nothing should survive: $interactive")
+
+ val full = NanoAriaSnapshotRenderer.render(root, fullOptions())
+ assertTrue(full.contains("generic \"Plain container\" [ref=e55]"), "Default mode keeps the div: $full")
+ }
+
+ @Test
+ @DisplayName("nano renderer: non-interactive container is dropped, interactive descendants are promoted")
+ fun nanoRendererPromotesInteractiveDescendantsOfSkippedContainers() {
+ val root = nanoElement(
+ "div",
+ refId = 1,
+ children = listOf(
+ nanoElement(
+ "div",
+ refId = 2,
+ children = listOf(
+ nanoElement("button", refId = 3, text = "Go")
+ )
+ )
+ )
+ )
+
+ val snapshot = NanoAriaSnapshotRenderer.render(root, interactiveOptions())
+
+ assertTrue(snapshot.contains("button \"Go\" [ref=e3]"), "Button should be promoted: $snapshot")
+ assertNoStructuralRoles(snapshot)
+ }
+
+ @Test
+ @DisplayName("nano renderer: interactable generic (cursor:pointer) survives interactive and compact mode")
+ fun nanoRendererKeepsInteractableGenericNode() {
+ val root = nanoElement(
+ "div",
+ refId = 1,
+ children = listOf(
+ nanoElement("div", refId = 2, interactive = true, text = "Clickable area")
+ )
+ )
+
+ val snapshot = NanoAriaSnapshotRenderer.render(
+ root,
+ AriaSnapshotOptions(interactive = true, boxes = false)
+ )
+
+ assertTrue(
+ snapshot.contains("generic \"Clickable area\" [ref=e2] [cursor=pointer]"),
+ "Interactable generic should be kept with cursor marker: $snapshot"
+ )
+ }
+
+ @Test
+ @DisplayName("nano renderer: input type matrix maps to interactive widget roles")
+ fun nanoRendererInputTypeMatrix() {
+ val types = mapOf(
+ "text" to "textbox",
+ "checkbox" to "checkbox",
+ "radio" to "radio",
+ "search" to "searchbox",
+ "range" to "slider",
+ "number" to "spinbutton",
+ "submit" to "button"
+ )
+ val root = nanoElement(
+ "div",
+ refId = 1,
+ children = types.keys.mapIndexed { index, type ->
+ nanoElement(
+ "input",
+ attrs = mapOf("type" to type, "aria-label" to "Field $type"),
+ refId = index + 2
+ )
+ }
+ )
+
+ val snapshot = NanoAriaSnapshotRenderer.render(root, interactiveOptions())
+ val roles = rolesOf(snapshot).toSet()
+
+ types.values.forEach { expectedRole ->
+ assertTrue(roles.contains(expectedRole), "Expected role $expectedRole in: $snapshot")
+ }
+ assertNoStructuralRoles(snapshot)
+ }
+
+ // ------------------------------------------------------ cross-renderer parity
+
+ @Test
+ @DisplayName("both renderers produce identical interactive-mode output for equivalent trees")
+ fun renderersAgreeOnInteractiveModeForEquivalentTrees() {
+ val options = AriaSnapshotOptions(interactive = true, boxes = false)
+
+ val ariaRoot = ariaElement(
+ "div",
+ backendNodeId = 1,
+ children = listOf(
+ ariaElement("h1", backendNodeId = 2, text = "Title"),
+ ariaElement(
+ "a",
+ attrs = mapOf("href" to "https://example.com", "aria-label" to "Docs"),
+ backendNodeId = 3,
+ text = "Docs"
+ ),
+ ariaElement("button", backendNodeId = 4, text = "Go"),
+ ariaElement(
+ "input",
+ attrs = mapOf("type" to "checkbox", "aria-label" to "Accept"),
+ backendNodeId = 5
+ ),
+ ariaElement("div", backendNodeId = 6, isInteractable = true, text = "Clickable area")
+ )
+ )
+ val nanoRoot = nanoElement(
+ "div",
+ refId = 1,
+ children = listOf(
+ nanoElement("h1", refId = 2, text = "Title"),
+ nanoElement(
+ "a",
+ attrs = mapOf("href" to "https://example.com", "aria-label" to "Docs"),
+ refId = 3,
+ text = "Docs"
+ ),
+ nanoElement("button", refId = 4, text = "Go"),
+ nanoElement(
+ "input",
+ attrs = mapOf("type" to "checkbox", "aria-label" to "Accept"),
+ refId = 5
+ ),
+ nanoElement("div", refId = 6, interactive = true, text = "Clickable area")
+ )
+ )
+
+ val full = AriaSnapshotRenderer.render(ariaRoot, options)
+ val nano = NanoAriaSnapshotRenderer.render(nanoRoot, options)
+
+ assertEquals(full, nano, "Both renderers must agree on interactive-mode output")
+ assertFalse(full.contains("heading"), "Heading must be filtered: $full")
+ assertTrue(
+ full.contains("generic \"Clickable area\" [ref=e6] [cursor=pointer]"),
+ "Interactable generic must be kept: $full"
+ )
+ }
+}
diff --git a/pulsar-core/pulsar-common/pom.xml b/pulsar-core/pulsar-common/pom.xml
index 643db86651..aefd399e8e 100644
--- a/pulsar-core/pulsar-common/pom.xml
+++ b/pulsar-core/pulsar-common/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-core-tests/pom.xml b/pulsar-core/pulsar-core-tests/pom.xml
index 184d089951..aec348ae1b 100644
--- a/pulsar-core/pulsar-core-tests/pom.xml
+++ b/pulsar-core/pulsar-core-tests/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar-core
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-core-tests
diff --git a/pulsar-core/pulsar-core-tests/pulsar-common-tests/pom.xml b/pulsar-core/pulsar-core-tests/pulsar-common-tests/pom.xml
index 94b490a9a1..2affe5b051 100644
--- a/pulsar-core/pulsar-core-tests/pulsar-common-tests/pom.xml
+++ b/pulsar-core/pulsar-core-tests/pulsar-common-tests/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../../pom.xml
diff --git a/pulsar-core/pulsar-core-tests/pulsar-dom-tests/pom.xml b/pulsar-core/pulsar-core-tests/pulsar-dom-tests/pom.xml
index 5a9ea221fd..fcd812b45b 100644
--- a/pulsar-core/pulsar-core-tests/pulsar-dom-tests/pom.xml
+++ b/pulsar-core/pulsar-core-tests/pulsar-dom-tests/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../../pom.xml
diff --git a/pulsar-core/pulsar-core-tests/pulsar-dom-tests/src/test/kotlin/ai/platon/pulsar/dom/select/TestImageQueries.kt b/pulsar-core/pulsar-core-tests/pulsar-dom-tests/src/test/kotlin/ai/platon/pulsar/dom/select/TestImageQueries.kt
new file mode 100644
index 0000000000..4524916b36
--- /dev/null
+++ b/pulsar-core/pulsar-core-tests/pulsar-dom-tests/src/test/kotlin/ai/platon/pulsar/dom/select/TestImageQueries.kt
@@ -0,0 +1,77 @@
+package ai.platon.pulsar.dom.select
+
+import ai.platon.pulsar.dom.Documents
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+
+/**
+ * Regression tests for https://github.com/platonai/Browser4base/issues/5
+ *
+ * DOM_*_IMG style helpers auto-append the target tag (`img` / `a`) to the css query
+ * when it is missing. The old implementation split the query on raw whitespace, so
+ * spaces inside a pseudo-class argument such as `:expr(width > 200)` broke the
+ * detection: the query was rewritten to `img:expr(width > 200) img`, which silently
+ * matches nothing (an image element cannot contain another image element).
+ */
+@DisplayName("Image scanning selector path (appendSelectorIfMissing / selectImages)")
+class TestImageQueries {
+
+ private val html = """
+
+
+
+ """.trimIndent()
+
+ private val doc = Documents.parse(html, "https://example.com")
+
+ @Test
+ @DisplayName("appendSelectorIfMissing must not append img when a :expr selector already targets img")
+ fun testAppendSelectorIfMissingKeepsExprSelectorUntouched() {
+ // issue #5: the spaces inside :expr(...) must not be treated as selector separators
+ assertEquals("img:expr(width > 200)", appendSelectorIfMissing("img:expr(width > 200)", "img"))
+ // same problem applies to :contains and attribute values that contain spaces
+ assertEquals("a:contains(Some Text)", appendSelectorIfMissing("a:contains(Some Text)", "a"))
+ assertEquals("img[src*=\"a b\"]", appendSelectorIfMissing("img[src*=\"a b\"]", "img"))
+ }
+
+ @Test
+ @DisplayName("appendSelectorIfMissing still appends the target tag when the query targets containers")
+ fun testAppendSelectorIfMissingStillAppendsWhenNeeded() {
+ assertEquals(":root img", appendSelectorIfMissing(":root", "img"))
+ assertEquals("div.gallery img", appendSelectorIfMissing("div.gallery", "img"))
+ assertEquals("body a", appendSelectorIfMissing("body", "a"))
+ // an expr on a container element still needs the img appended
+ assertEquals("div:expr(width > 100) img", appendSelectorIfMissing("div:expr(width > 100)", "img"))
+ }
+
+ @Test
+ @DisplayName("appendSelectorIfMissing keeps existing target tags untouched")
+ fun testAppendSelectorIfMissingKeepsExistingTargets() {
+ assertEquals("div.gallery img", appendSelectorIfMissing("div.gallery img", "img"))
+ assertEquals("article > img.nav", appendSelectorIfMissing("article > img.nav", "img"))
+ assertEquals("a[href]", appendSelectorIfMissing("a[href]", "a"))
+ }
+
+ @Test
+ @DisplayName("PowerCSS :expr selector with spaces evaluates when querying the document")
+ fun testSelectExprWithSpacesFindsWideImages() {
+ val imgs = doc.select("img:expr(width > 200)")
+ assertEquals(listOf("wide1", "wide2"), imgs.map { it.id() })
+ }
+
+ @Test
+ @DisplayName("selectImages (img scanning helper) evaluates an :expr selector with spaces")
+ fun testSelectImagesWithExprSelector() {
+ // issue #5 repro: the query must not be rewritten into "img:expr(width > 200) img"
+ val srcs = doc.selectImages("img:expr(width > 200)")
+ assertEquals(
+ listOf("https://example.com/img/wide1.jpg", "https://example.com/img/wide2.jpg"),
+ srcs
+ )
+ }
+}
diff --git a/pulsar-core/pulsar-core-tests/pulsar-ql-tests/pom.xml b/pulsar-core/pulsar-core-tests/pulsar-ql-tests/pom.xml
index 8aaef0f4e7..5780cbeca3 100644
--- a/pulsar-core/pulsar-core-tests/pulsar-ql-tests/pom.xml
+++ b/pulsar-core/pulsar-core-tests/pulsar-ql-tests/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar-core-tests
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-ql-tests
diff --git a/pulsar-core/pulsar-core-tests/pulsar-ql-tests/src/test/kotlin/ai/platon/pulsar/ql/h2/udfs/DomImageFunctionExprTests.kt b/pulsar-core/pulsar-core-tests/pulsar-ql-tests/src/test/kotlin/ai/platon/pulsar/ql/h2/udfs/DomImageFunctionExprTests.kt
new file mode 100644
index 0000000000..d3ca8d5119
--- /dev/null
+++ b/pulsar-core/pulsar-core-tests/pulsar-ql-tests/src/test/kotlin/ai/platon/pulsar/ql/h2/udfs/DomImageFunctionExprTests.kt
@@ -0,0 +1,60 @@
+package ai.platon.pulsar.ql.h2.udfs
+
+import ai.platon.pulsar.dom.Documents
+import ai.platon.pulsar.ql.common.types.ValueDom
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+
+/**
+ * Regression tests for https://github.com/platonai/Browser4base/issues/5
+ *
+ * `DOM_FIRST_IMG` / `DOM_NTH_IMG` / `DOM_ALL_IMGS` with a PowerCSS `:expr(...)`
+ * selector used to silently match nothing: the selector argument was rewritten by
+ * [appendSelectorIfMissing] into e.g. `img:expr(width > 200) img` (spaces inside
+ * the expression were mistaken for selector separators), which never matches.
+ */
+@DisplayName("DOM image helpers with PowerCSS :expr() selectors")
+class DomImageFunctionExprTests {
+
+ private val html = """
+
+
+
+ """.trimIndent()
+
+ private val dom = ValueDom.get(Documents.parse(html, "https://example.com"))
+
+ @Test
+ @DisplayName("DOM_FIRST_IMG evaluates :expr(width > 200) and returns the first wide image src")
+ fun testFirstImgWithExprSelector() {
+ assertEquals(
+ "https://example.com/img/wide1.jpg",
+ DomSelectFunctions.firstImg(dom, "img:expr(width > 200)")
+ )
+ }
+
+ @Test
+ @DisplayName("DOM_NTH_IMG evaluates :expr(width > 200) and returns the nth wide image src")
+ fun testNthImgWithExprSelector() {
+ assertEquals(
+ "https://example.com/img/wide2.jpg",
+ DomSelectFunctions.nthImg(dom, "img:expr(width > 200)", 2)
+ )
+ }
+
+ @Test
+ @DisplayName("DOM_ALL_IMGS evaluates :expr(width > 200) and returns every wide image src")
+ fun testAllImgsWithExprSelector() {
+ val all = DomSelectFunctions.allImgs(dom, "img:expr(width > 200)")
+ assertEquals(2, all.list.size)
+ assertEquals(
+ listOf("https://example.com/img/wide1.jpg", "https://example.com/img/wide2.jpg"),
+ all.list.map { it.string }
+ )
+ }
+}
diff --git a/pulsar-core/pulsar-dom/pom.xml b/pulsar-core/pulsar-dom/pom.xml
index 47a1cabf79..880c2fc75d 100644
--- a/pulsar-core/pulsar-dom/pom.xml
+++ b/pulsar-core/pulsar-dom/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-dom/src/main/kotlin/ai/platon/pulsar/dom/select/DomQueries.kt b/pulsar-core/pulsar-dom/src/main/kotlin/ai/platon/pulsar/dom/select/DomQueries.kt
index 7b0fca68ea..a52c6c0bcd 100644
--- a/pulsar-core/pulsar-dom/src/main/kotlin/ai/platon/pulsar/dom/select/DomQueries.kt
+++ b/pulsar-core/pulsar-dom/src/main/kotlin/ai/platon/pulsar/dom/select/DomQueries.kt
@@ -313,15 +313,81 @@ fun Node.selectImages(query: String, offset: Int = 1, limit: Int = Int.MAX_VALUE
.filterNotNull()
}
+/**
+ * Append the given element selector to the css query if the query does not already
+ * select that element type, e.g. append `img` to `div.gallery` to select images inside
+ * the gallery: `div.gallery img`.
+ *
+ * Whitespace inside pseudo-class arguments (`:expr(width > 200)`, `:contains(Some Text)`)
+ * and inside quoted attribute values is part of the selector argument, not a selector
+ * separator. Splitting on raw whitespace would rewrite `img:expr(width > 200)` into
+ * `img:expr(width > 200) img`, which silently matches nothing.
+ *
+ * @see issue #5
+ */
fun appendSelectorIfMissing(cssQuery: String, appendix: String): String {
- var q = cssQuery.replace("\\s+".toRegex(), " ").trim()
+ val q = cssQuery.replace("\\s+".toRegex(), " ").trim()
val ap = appendix.trim()
- val parts = q.split(" ")
+ val parts = topLevelParts(q)
// consider: body > div:nth-child(10) > ul > li:nth-child(3) > a:nth-child(2)
- if (!parts[parts.size - 1].startsWith(ap, ignoreCase = true)) {
- q += " $ap"
+ if (parts.isEmpty() || !parts.last().startsWith(ap, ignoreCase = true)) {
+ return "$q $ap"
}
return q
}
+
+/**
+ * Split a css query at whitespace that is not inside parentheses, brackets or quoted
+ * strings. Spaces in pseudo-class arguments and attribute values must be kept intact,
+ * e.g. `img:expr(width > 200)` or `a:contains(Some Text)` must not be split.
+ */
+private fun topLevelParts(cssQuery: String): List {
+ val parts = mutableListOf()
+ val part = StringBuilder()
+ var depth = 0
+ var quote: Char? = null
+
+ for (c in cssQuery) {
+ if (quote != null) {
+ part.append(c)
+ if (c == quote) {
+ quote = null
+ }
+ continue
+ }
+
+ when {
+ c == '"' || c == '\'' -> {
+ quote = c
+ part.append(c)
+ }
+
+ c == '(' || c == '[' -> {
+ ++depth
+ part.append(c)
+ }
+
+ c == ')' || c == ']' -> {
+ if (depth > 0) {
+ --depth
+ }
+ part.append(c)
+ }
+
+ c == ' ' && depth == 0 -> {
+ parts.add(part.toString())
+ part.setLength(0)
+ }
+
+ else -> part.append(c)
+ }
+ }
+
+ if (part.isNotEmpty()) {
+ parts.add(part.toString())
+ }
+
+ return parts
+}
diff --git a/pulsar-core/pulsar-parse/pom.xml b/pulsar-core/pulsar-parse/pom.xml
index 93dd2b854e..ea790d9e70 100644
--- a/pulsar-core/pulsar-parse/pom.xml
+++ b/pulsar-core/pulsar-parse/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar-core
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-parse
diff --git a/pulsar-core/pulsar-persist-mongo/pom.xml b/pulsar-core/pulsar-persist-mongo/pom.xml
index 26a758a475..7ea2547380 100644
--- a/pulsar-core/pulsar-persist-mongo/pom.xml
+++ b/pulsar-core/pulsar-persist-mongo/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-persist/pom.xml b/pulsar-core/pulsar-persist/pom.xml
index 33cdbb20fb..eb5797fad6 100644
--- a/pulsar-core/pulsar-persist/pom.xml
+++ b/pulsar-core/pulsar-persist/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-protocol/pom.xml b/pulsar-core/pulsar-protocol/pom.xml
index 9b9f00930c..a0c30df554 100644
--- a/pulsar-core/pulsar-protocol/pom.xml
+++ b/pulsar-core/pulsar-protocol/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar-core
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-protocol
diff --git a/pulsar-core/pulsar-ql-common/pom.xml b/pulsar-core/pulsar-ql-common/pom.xml
index af81be1f31..bafa97bea6 100644
--- a/pulsar-core/pulsar-ql-common/pom.xml
+++ b/pulsar-core/pulsar-ql-common/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-ql/pom.xml b/pulsar-core/pulsar-ql/pom.xml
index ba26b75335..46259df126 100644
--- a/pulsar-core/pulsar-ql/pom.xml
+++ b/pulsar-core/pulsar-ql/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-resources/pom.xml b/pulsar-core/pulsar-resources/pom.xml
index 35422bc1d2..6810add076 100644
--- a/pulsar-core/pulsar-resources/pom.xml
+++ b/pulsar-core/pulsar-resources/pom.xml
@@ -3,7 +3,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-skeleton/pom.xml b/pulsar-core/pulsar-skeleton/pom.xml
index 427b00d3bc..abc22ce3f1 100644
--- a/pulsar-core/pulsar-skeleton/pom.xml
+++ b/pulsar-core/pulsar-skeleton/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-third/pom.xml b/pulsar-core/pulsar-third/pom.xml
index 5b690aa36d..052fe0c850 100644
--- a/pulsar-core/pulsar-third/pom.xml
+++ b/pulsar-core/pulsar-third/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-core/pulsar-third/pulsar-llm/pom.xml b/pulsar-core/pulsar-third/pulsar-llm/pom.xml
index 50bb42be00..37741ef315 100644
--- a/pulsar-core/pulsar-third/pulsar-llm/pom.xml
+++ b/pulsar-core/pulsar-third/pulsar-llm/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar-third
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-llm
diff --git a/pulsar-coverage-report/pom.xml b/pulsar-coverage-report/pom.xml
index 14add01b49..7fca0f19af 100644
--- a/pulsar-coverage-report/pom.xml
+++ b/pulsar-coverage-report/pom.xml
@@ -7,7 +7,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-coverage-report
diff --git a/pulsar-dependencies/pom.xml b/pulsar-dependencies/pom.xml
index 807849993c..c4a6a202d8 100644
--- a/pulsar-dependencies/pom.xml
+++ b/pulsar-dependencies/pom.xml
@@ -17,7 +17,7 @@
ai.platon
pulsar-dependencies
Pulsar Dependencies
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pom
Pulsar Dependencies
diff --git a/pulsar-sdk/pom.xml b/pulsar-sdk/pom.xml
index e7f4b7359e..357316f469 100644
--- a/pulsar-sdk/pom.xml
+++ b/pulsar-sdk/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-sdk
diff --git a/pulsar-spring-support/pom.xml b/pulsar-spring-support/pom.xml
index 3fb62cce6a..0cc4636b44 100644
--- a/pulsar-spring-support/pom.xml
+++ b/pulsar-spring-support/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-spring-support
diff --git a/pulsar-spring-support/pulsar-boot/pom.xml b/pulsar-spring-support/pulsar-boot/pom.xml
index 3b88712183..e9860e453a 100644
--- a/pulsar-spring-support/pulsar-boot/pom.xml
+++ b/pulsar-spring-support/pulsar-boot/pom.xml
@@ -15,7 +15,7 @@
ai.platon.pulsar
pulsar-spring-support
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
pulsar-boot
diff --git a/pulsar-tests/pulsar-e2e-tests/pom.xml b/pulsar-tests/pulsar-e2e-tests/pom.xml
index 5420db8f4c..4d42e1b99a 100644
--- a/pulsar-tests/pulsar-e2e-tests/pom.xml
+++ b/pulsar-tests/pulsar-e2e-tests/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-tests/pulsar-e2e-tests/src/test/kotlin/ai/platon/pulsar/chrome/dom/AriaSnapshotRendererE2ETest.kt b/pulsar-tests/pulsar-e2e-tests/src/test/kotlin/ai/platon/pulsar/chrome/dom/AriaSnapshotRendererE2ETest.kt
index 4416d76e77..e0d1eaadd2 100644
--- a/pulsar-tests/pulsar-e2e-tests/src/test/kotlin/ai/platon/pulsar/chrome/dom/AriaSnapshotRendererE2ETest.kt
+++ b/pulsar-tests/pulsar-e2e-tests/src/test/kotlin/ai/platon/pulsar/chrome/dom/AriaSnapshotRendererE2ETest.kt
@@ -6,7 +6,9 @@ import ai.platon.pulsar.api.BrowserProtocol
import ai.platon.pulsar.api.model.PageTarget
import ai.platon.pulsar.api.model.SnapshotOptions
import ai.platon.pulsar.chrome.dom.CDPSnapshotService
+import ai.platon.pulsar.chrome.dom.model.AriaSnapshotOptions
import kotlinx.coroutines.delay
+import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
@@ -62,6 +64,30 @@ class AriaSnapshotRendererE2ETest : WebDriverTestBase() {
assertTrue(normalized.contains("- generic \"element title\" [ref=#]"), normalized)
}
+ @Test
+ @DisplayName("Render strict interactive-only aria snapshot on a real server-hosted page")
+ fun renderInteractiveOnlyAriaSnapshotOnRealFixturePage() =
+ runWebDriverTestAndCompute(rendererFixtureURL) { driver ->
+ assertIs(driver)
+ driver.waitForSelector("h1")
+ driver.bringToFront()
+
+ installRendererFixture(driver.browserProtocol)
+ driver.waitForSelector("h1")
+
+ val service = CDPSnapshotService(driver.browserProtocol)
+ val normalized = normalizeRefs(collectInteractiveAriaSnapshot(service)).lowercase()
+
+ assertTrue(normalized.contains("- button \"collapsed button\" [ref=#]"), normalized)
+ assertTrue(normalized.contains("- button \"button\" [ref=#]"), normalized)
+ assertTrue(normalized.contains("- link \"link with a button button\" [ref=#]"), normalized)
+ assertTrue(normalized.contains("- textbox \"search\" [ref=#]"), normalized)
+ assertTrue(normalized.contains("- /placeholder: search docs"), normalized)
+ assertFalse(normalized.contains("- heading"), normalized)
+ assertFalse(normalized.contains("- region"), normalized)
+ assertFalse(normalized.contains("element title"), "Titled generic div should be filtered out: $normalized")
+ }
+
@Test
@DisplayName("Render iframe nodes and nested frame content on a real frames page")
fun renderIframeNodesAndNestedFrameContentOnRealFramesPage() = runWebDriverTestAndCompute(nestedFramesURL) { driver ->
@@ -112,6 +138,19 @@ class AriaSnapshotRendererE2ETest : WebDriverTestBase() {
return domState.ariaSnapshot
}
+ private suspend fun collectInteractiveAriaSnapshot(service: CDPSnapshotService): String {
+ val trees = service.buildTargetTrees(target = PageTarget(), options = snapshotOptions)
+ assertTrue(trees.axTree.isNotEmpty(), "AX tree should be collected for aria snapshot rendering")
+
+ val enhancedRoot = collectEnhancedRoot(service, snapshotOptions)
+ val optimizedTree = service.buildOptimizedDOMTreeNode(enhancedRoot)
+ val domState = service.buildDOMState(optimizedTree)
+
+ val snapshot = domState.renderedAriaSnapshot(AriaSnapshotOptions(interactive = true))
+ assertTrue(snapshot.isNotBlank(), "Interactive aria snapshot should not be blank")
+ return snapshot
+ }
+
private fun normalizeRefs(snapshot: String): String {
return snapshot
.replace(Regex("""\[ref=[^\]]+]"""), "[ref=#]")
diff --git a/pulsar-tests/pulsar-it-tests/pom.xml b/pulsar-tests/pulsar-it-tests/pom.xml
index dee9f0a69d..49ab23acb0 100644
--- a/pulsar-tests/pulsar-it-tests/pom.xml
+++ b/pulsar-tests/pulsar-it-tests/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml
diff --git a/pulsar-tests/pulsar-tests-common/pom.xml b/pulsar-tests/pulsar-tests-common/pom.xml
index 2879ddb73e..e1807700e1 100644
--- a/pulsar-tests/pulsar-tests-common/pom.xml
+++ b/pulsar-tests/pulsar-tests-common/pom.xml
@@ -14,7 +14,7 @@
ai.platon.pulsar
pulsar
- 4.11.12-SNAPSHOT
+ 4.11.14-SNAPSHOT
../../pom.xml