From b019b5e48cc59f8e20208c7c3dfb2cdd32ea523c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 4 Sep 2026 03:40:51 +0000 Subject: [PATCH 1/5] chore: bump version to 4.11.13-SNAPSHOT after release Post-release version bump to the next patch snapshot. Co-Authored-By: Claude --- VERSION | 2 +- pulsar-bom/pom.xml | 2 +- pulsar-core/pom.xml | 2 +- pulsar-coverage-report/pom.xml | 2 +- pulsar-dependencies/pom.xml | 2 +- pulsar-sdk/pom.xml | 2 +- pulsar-spring-support/pom.xml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 166a2faac..251e940be 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.11.12-SNAPSHOT +4.11.13-SNAPSHOT diff --git a/pulsar-bom/pom.xml b/pulsar-bom/pom.xml index ee71975c9..791b5665c 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.13-SNAPSHOT pom diff --git a/pulsar-core/pom.xml b/pulsar-core/pom.xml index 59536859b..fef01db6e 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.13-SNAPSHOT pulsar-core diff --git a/pulsar-coverage-report/pom.xml b/pulsar-coverage-report/pom.xml index 14add01b4..d4fa01b7f 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.13-SNAPSHOT pulsar-coverage-report diff --git a/pulsar-dependencies/pom.xml b/pulsar-dependencies/pom.xml index 807849993..184cc3995 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.13-SNAPSHOT pom Pulsar Dependencies diff --git a/pulsar-sdk/pom.xml b/pulsar-sdk/pom.xml index e7f4b7359..22f3e1947 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.13-SNAPSHOT pulsar-sdk diff --git a/pulsar-spring-support/pom.xml b/pulsar-spring-support/pom.xml index 3fb62cce6..51d8f4e72 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.13-SNAPSHOT pulsar-spring-support From 0d8646c25aa3fee0febde261ba7823367c608bf7 Mon Sep 17 00:00:00 2001 From: "platon.AI" Date: Fri, 4 Sep 2026 13:05:37 +0800 Subject: [PATCH 2/5] fix(ci): complete post-release version bump to 4.11.13-SNAPSHOT The release bump-version job updated 28 pom.xml files in the workspace, but 'git add VERSION **/pom.xml' only staged one-directory-deep POMs (bash without globstar treats '**' as '*'). The root parent POM and all nested module POMs stayed on 4.11.12-SNAPSHOT, breaking parent POM resolution in CI. - Bump the remaining 22 POMs (root + nested modules) to 4.11.13-SNAPSHOT - Stage the root pom.xml explicitly in release.yml and quote the glob so git's own pathspec expands '**' to any depth Co-Authored-By: Claude --- .github/workflows/release.yml | 6 +++++- pom.xml | 2 +- pulsar-core/pulsar-browser/pom.xml | 2 +- pulsar-core/pulsar-common/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pulsar-common-tests/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pulsar-dom-tests/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pulsar-ql-tests/pom.xml | 2 +- pulsar-core/pulsar-dom/pom.xml | 2 +- pulsar-core/pulsar-parse/pom.xml | 2 +- pulsar-core/pulsar-persist-mongo/pom.xml | 2 +- pulsar-core/pulsar-persist/pom.xml | 2 +- pulsar-core/pulsar-protocol/pom.xml | 2 +- pulsar-core/pulsar-ql-common/pom.xml | 2 +- pulsar-core/pulsar-ql/pom.xml | 2 +- pulsar-core/pulsar-resources/pom.xml | 2 +- pulsar-core/pulsar-skeleton/pom.xml | 2 +- pulsar-core/pulsar-third/pom.xml | 2 +- pulsar-core/pulsar-third/pulsar-llm/pom.xml | 2 +- pulsar-spring-support/pulsar-boot/pom.xml | 2 +- pulsar-tests/pulsar-e2e-tests/pom.xml | 2 +- pulsar-tests/pulsar-it-tests/pom.xml | 2 +- pulsar-tests/pulsar-tests-common/pom.xml | 2 +- 23 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ad4bec07..da65bab3a 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/pom.xml b/pom.xml index b08e53b2f..5574224f3 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ ai.platon.pulsar pulsar - 4.11.12-SNAPSHOT + 4.11.13-SNAPSHOT pom Browser4 Base diff --git a/pulsar-core/pulsar-browser/pom.xml b/pulsar-core/pulsar-browser/pom.xml index 81c074b6c..a6823a7f9 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-common/pom.xml b/pulsar-core/pulsar-common/pom.xml index 643db8665..4a7508939 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-core-tests/pom.xml b/pulsar-core/pulsar-core-tests/pom.xml index 184d08995..15e387e21 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.13-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 94b490a9a..3ec47ad4c 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.13-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 5a9ea221f..0175ed65b 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.13-SNAPSHOT ../../../pom.xml 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 8aaef0f4e..a76f3084d 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.13-SNAPSHOT pulsar-ql-tests diff --git a/pulsar-core/pulsar-dom/pom.xml b/pulsar-core/pulsar-dom/pom.xml index 47a1cabf7..f7dcd2512 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-parse/pom.xml b/pulsar-core/pulsar-parse/pom.xml index 93dd2b854..f065c25f5 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.13-SNAPSHOT pulsar-parse diff --git a/pulsar-core/pulsar-persist-mongo/pom.xml b/pulsar-core/pulsar-persist-mongo/pom.xml index 26a758a47..46ddc5928 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-persist/pom.xml b/pulsar-core/pulsar-persist/pom.xml index 33cdbb20f..b9e591542 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-protocol/pom.xml b/pulsar-core/pulsar-protocol/pom.xml index 9b9f00930..7cf9bbfd1 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.13-SNAPSHOT pulsar-protocol diff --git a/pulsar-core/pulsar-ql-common/pom.xml b/pulsar-core/pulsar-ql-common/pom.xml index af81be1f3..13bad09c1 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-ql/pom.xml b/pulsar-core/pulsar-ql/pom.xml index ba26b7533..72f4b46ad 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-resources/pom.xml b/pulsar-core/pulsar-resources/pom.xml index 35422bc1d..52f081c07 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-skeleton/pom.xml b/pulsar-core/pulsar-skeleton/pom.xml index 427b00d3b..f4598a265 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-third/pom.xml b/pulsar-core/pulsar-third/pom.xml index 5b690aa36..3c6254ea1 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-third/pulsar-llm/pom.xml b/pulsar-core/pulsar-third/pulsar-llm/pom.xml index 50bb42be0..6d5708e42 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.13-SNAPSHOT pulsar-llm diff --git a/pulsar-spring-support/pulsar-boot/pom.xml b/pulsar-spring-support/pulsar-boot/pom.xml index 3b8871218..b21964856 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.13-SNAPSHOT pulsar-boot diff --git a/pulsar-tests/pulsar-e2e-tests/pom.xml b/pulsar-tests/pulsar-e2e-tests/pom.xml index 5420db8f4..54dc35bc3 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-tests/pulsar-it-tests/pom.xml b/pulsar-tests/pulsar-it-tests/pom.xml index dee9f0a69..e0fbb66e2 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.13-SNAPSHOT ../../pom.xml diff --git a/pulsar-tests/pulsar-tests-common/pom.xml b/pulsar-tests/pulsar-tests-common/pom.xml index 2879ddb73..804b545f8 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.13-SNAPSHOT ../../pom.xml From 5202aab73af2907e1bf5cf9495b0ca5ff0105abc Mon Sep 17 00:00:00 2001 From: "platon.AI" Date: Sat, 5 Sep 2026 22:35:15 +0800 Subject: [PATCH 3/5] fix(browser): make aria snapshot interactive filter strict and unify renderer predicates Both renderers treated ref-addressability (backendNodeId/locator) as an interactivity signal: the 'ref != null' / 'ref > 0' early returns qualified virtually every DOM node (CDP assigns backend node ids broadly), turning AriaSnapshotOptions(interactive = true) into a no-op filter that leaked headings, paragraphs, listitems and generic containers into interactive-only output. - Add AriaSnapshotFiltering as the single shared predicate: a role widget (INTERACTIVE_ROLES) or an isInteractable signal qualifies; refs never qualify. - AriaSnapshotRenderer and NanoAriaSnapshotRenderer now call the shared predicate, so viewport/nano and whole-page/full renderers agree on the same DOM. - While interactive mode is active, skip --compact collapsing so kept interactive nodes (e.g. nameless cursor:pointer divs) are not dropped. - Unit tests for both renderers (structural roles excluded even with refs, plain div with backendNodeId does not survive, descendants promoted, input type matrix, identical output across renderers) plus an E2E strict-mode test. Closes #3 Co-Authored-By: Claude --- .../api/snapshot/AriaSnapshotFiltering.kt | 40 ++ .../api/snapshot/AriaSnapshotRenderer.kt | 31 +- .../api/snapshot/NanoAriaSnapshotRenderer.kt | 28 +- .../chrome/dom/model/AriaSnapshotOptions.kt | 12 +- .../snapshot/AriaSnapshotInteractiveTest.kt | 480 ++++++++++++++++++ .../chrome/dom/AriaSnapshotRendererE2ETest.kt | 39 ++ 6 files changed, 587 insertions(+), 43 deletions(-) create mode 100644 pulsar-core/pulsar-browser/src/main/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotFiltering.kt create mode 100644 pulsar-core/pulsar-browser/src/test/kotlin/ai/platon/pulsar/api/snapshot/AriaSnapshotInteractiveTest.kt 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 000000000..a072f1dbc --- /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 3616b1d1c..55951ac0a 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 69b0d18af..a1c64fd9e 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 2eb751ebc..0dc712fe3 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 000000000..146f57202 --- /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-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 4416d76e7..e0d1eaadd 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=#]") From 438976162871c48fa5c3f3367acd050e8070e1d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 5 Sep 2026 15:24:04 +0000 Subject: [PATCH 4/5] chore: bump version to 4.11.14-SNAPSHOT after release Post-release version bump to the next patch snapshot. Co-Authored-By: Claude --- VERSION | 2 +- pom.xml | 2 +- pulsar-bom/pom.xml | 2 +- pulsar-core/pom.xml | 2 +- pulsar-core/pulsar-browser/pom.xml | 2 +- pulsar-core/pulsar-common/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pulsar-common-tests/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pulsar-dom-tests/pom.xml | 2 +- pulsar-core/pulsar-core-tests/pulsar-ql-tests/pom.xml | 2 +- pulsar-core/pulsar-dom/pom.xml | 2 +- pulsar-core/pulsar-parse/pom.xml | 2 +- pulsar-core/pulsar-persist-mongo/pom.xml | 2 +- pulsar-core/pulsar-persist/pom.xml | 2 +- pulsar-core/pulsar-protocol/pom.xml | 2 +- pulsar-core/pulsar-ql-common/pom.xml | 2 +- pulsar-core/pulsar-ql/pom.xml | 2 +- pulsar-core/pulsar-resources/pom.xml | 2 +- pulsar-core/pulsar-skeleton/pom.xml | 2 +- pulsar-core/pulsar-third/pom.xml | 2 +- pulsar-core/pulsar-third/pulsar-llm/pom.xml | 2 +- pulsar-coverage-report/pom.xml | 2 +- pulsar-dependencies/pom.xml | 2 +- pulsar-sdk/pom.xml | 2 +- pulsar-spring-support/pom.xml | 2 +- pulsar-spring-support/pulsar-boot/pom.xml | 2 +- pulsar-tests/pulsar-e2e-tests/pom.xml | 2 +- pulsar-tests/pulsar-it-tests/pom.xml | 2 +- pulsar-tests/pulsar-tests-common/pom.xml | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/VERSION b/VERSION index 251e940be..57d857da1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.11.13-SNAPSHOT +4.11.14-SNAPSHOT diff --git a/pom.xml b/pom.xml index 5574224f3..35ef0d58f 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ ai.platon.pulsar pulsar - 4.11.13-SNAPSHOT + 4.11.14-SNAPSHOT pom Browser4 Base diff --git a/pulsar-bom/pom.xml b/pulsar-bom/pom.xml index 791b5665c..4f4334ca2 100644 --- a/pulsar-bom/pom.xml +++ b/pulsar-bom/pom.xml @@ -16,7 +16,7 @@ ai.platon.pulsar pulsar-bom Pulsar BOM - 4.11.13-SNAPSHOT + 4.11.14-SNAPSHOT pom diff --git a/pulsar-core/pom.xml b/pulsar-core/pom.xml index fef01db6e..32b4948ec 100644 --- a/pulsar-core/pom.xml +++ b/pulsar-core/pom.xml @@ -14,7 +14,7 @@ ai.platon.pulsar pulsar - 4.11.13-SNAPSHOT + 4.11.14-SNAPSHOT pulsar-core diff --git a/pulsar-core/pulsar-browser/pom.xml b/pulsar-core/pulsar-browser/pom.xml index a6823a7f9..7d9d4c5f3 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-common/pom.xml b/pulsar-core/pulsar-common/pom.xml index 4a7508939..aefd399e8 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.13-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 15e387e21..aec348ae1 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.13-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 3ec47ad4c..2affe5b05 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.13-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 0175ed65b..fcd812b45 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../../pom.xml 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 a76f3084d..5780cbeca 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.13-SNAPSHOT + 4.11.14-SNAPSHOT pulsar-ql-tests diff --git a/pulsar-core/pulsar-dom/pom.xml b/pulsar-core/pulsar-dom/pom.xml index f7dcd2512..880c2fc75 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-parse/pom.xml b/pulsar-core/pulsar-parse/pom.xml index f065c25f5..ea790d9e7 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.13-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 46ddc5928..7ea254738 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-persist/pom.xml b/pulsar-core/pulsar-persist/pom.xml index b9e591542..eb5797fad 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-protocol/pom.xml b/pulsar-core/pulsar-protocol/pom.xml index 7cf9bbfd1..a0c30df55 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.13-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 13bad09c1..bafa97bea 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-ql/pom.xml b/pulsar-core/pulsar-ql/pom.xml index 72f4b46ad..46259df12 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-resources/pom.xml b/pulsar-core/pulsar-resources/pom.xml index 52f081c07..6810add07 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-skeleton/pom.xml b/pulsar-core/pulsar-skeleton/pom.xml index f4598a265..abc22ce3f 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-core/pulsar-third/pom.xml b/pulsar-core/pulsar-third/pom.xml index 3c6254ea1..052fe0c85 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.13-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 6d5708e42..37741ef31 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.13-SNAPSHOT + 4.11.14-SNAPSHOT pulsar-llm diff --git a/pulsar-coverage-report/pom.xml b/pulsar-coverage-report/pom.xml index d4fa01b7f..7fca0f19a 100644 --- a/pulsar-coverage-report/pom.xml +++ b/pulsar-coverage-report/pom.xml @@ -7,7 +7,7 @@ ai.platon.pulsar pulsar - 4.11.13-SNAPSHOT + 4.11.14-SNAPSHOT pulsar-coverage-report diff --git a/pulsar-dependencies/pom.xml b/pulsar-dependencies/pom.xml index 184cc3995..c4a6a202d 100644 --- a/pulsar-dependencies/pom.xml +++ b/pulsar-dependencies/pom.xml @@ -17,7 +17,7 @@ ai.platon pulsar-dependencies Pulsar Dependencies - 4.11.13-SNAPSHOT + 4.11.14-SNAPSHOT pom Pulsar Dependencies diff --git a/pulsar-sdk/pom.xml b/pulsar-sdk/pom.xml index 22f3e1947..357316f46 100644 --- a/pulsar-sdk/pom.xml +++ b/pulsar-sdk/pom.xml @@ -15,7 +15,7 @@ ai.platon.pulsar pulsar - 4.11.13-SNAPSHOT + 4.11.14-SNAPSHOT pulsar-sdk diff --git a/pulsar-spring-support/pom.xml b/pulsar-spring-support/pom.xml index 51d8f4e72..0cc4636b4 100644 --- a/pulsar-spring-support/pom.xml +++ b/pulsar-spring-support/pom.xml @@ -15,7 +15,7 @@ ai.platon.pulsar pulsar - 4.11.13-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 b21964856..e9860e453 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.13-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 54dc35bc3..4d42e1b99 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml diff --git a/pulsar-tests/pulsar-it-tests/pom.xml b/pulsar-tests/pulsar-it-tests/pom.xml index e0fbb66e2..49ab23acb 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.13-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 804b545f8..e1807700e 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.13-SNAPSHOT + 4.11.14-SNAPSHOT ../../pom.xml From a8937907691db78c659f14d989d7de0016e33a8e Mon Sep 17 00:00:00 2001 From: "platon.AI" Date: Mon, 7 Sep 2026 02:10:03 +0800 Subject: [PATCH 5/5] fix(dom): honor PowerCSS :expr() in image/link selector arguments DOM_*_IMG / DOM_*_HREF helpers auto-append the target tag (img/a) to the css query when it is missing. appendSelectorIfMissing split the query on raw whitespace, so spaces inside a pseudo-class argument such as img:expr(width > 200) broke the detection and the query was rewritten to "img:expr(width > 200) img", which silently matches nothing (an image cannot contain an image): empty column, exit 0. Split only on top-level whitespace, ignoring spaces inside parentheses, brackets and quoted strings, so img:expr(width > 200), a:contains(Some Text) and img[src*="a b"] are left intact while ":root" / "div.gallery" still get "img" appended. Closes https://github.com/platonai/Browser4base/issues/5 Co-Authored-By: Claude --- docs/ql-functions-guide.md | 7 ++ .../pulsar/dom/select/TestImageQueries.kt | 77 +++++++++++++++++++ .../ql/h2/udfs/DomImageFunctionExprTests.kt | 60 +++++++++++++++ .../ai/platon/pulsar/dom/select/DomQueries.kt | 74 +++++++++++++++++- 4 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 pulsar-core/pulsar-core-tests/pulsar-dom-tests/src/test/kotlin/ai/platon/pulsar/dom/select/TestImageQueries.kt create mode 100644 pulsar-core/pulsar-core-tests/pulsar-ql-tests/src/test/kotlin/ai/platon/pulsar/ql/h2/udfs/DomImageFunctionExprTests.kt diff --git a/docs/ql-functions-guide.md b/docs/ql-functions-guide.md index 9604789e3..94be8f903 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/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 000000000..4524916b3 --- /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/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 000000000..d3ca8d511 --- /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/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 7b0fca68e..a52c6c0bc 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 +}