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