fix(computer-use): resolve duplicate AX groups

This commit is contained in:
程序员阿江(Relakkes)
2026-09-02 11:27:16 +08:00
parent 5520c62b35
commit e28d0a6534
4 changed files with 165 additions and 25 deletions
@@ -600,13 +600,20 @@ public enum AXTree {
/// Re-walk a freshly resolved current root through immutable topology
/// evidence. Every hop compares the complete ordered sibling fingerprint
/// list before using its selected ordinal.
/// list; duplicate wrappers additionally require unique direct-child
/// topology before one candidate can be selected.
private static func walk(root: AXUIElement, path: [SnapshotPathStep]) -> AXUIElement? {
var current = root
for step in path {
let kids = walkChildren(of: current)
let currentFingerprints = kids.map(fingerprint(of:))
guard let childIndex = step.selectedIndex(in: currentFingerprints) else {
guard let childIndex = step.selectedIndex(
in: currentFingerprints,
childTopologyAt: { index in
guard kids.indices.contains(index) else { return nil }
return walkChildren(of: kids[index]).map(fingerprint(of:))
}
) else {
return nil
}
current = kids[childIndex]
@@ -1035,6 +1042,7 @@ public enum AXTree {
snapshotPath: appendingPathStep(
to: snapshotPath,
selectedIndex: i,
childElements: childElements,
childFingerprints: childFingerprints
),
depth: depth,
@@ -1172,6 +1180,7 @@ public enum AXTree {
snapshotPath: appendingPathStep(
to: snapshotPath,
selectedIndex: i,
childElements: childElements,
childFingerprints: childFingerprints
),
depth: depth + 1,
@@ -1267,12 +1276,18 @@ public enum AXTree {
private func appendingPathStep(
to path: [SnapshotPathStep]?,
selectedIndex: Int,
childElements: [AXUIElement],
childFingerprints: [ElementFingerprint]
) -> [SnapshotPathStep]? {
guard let path,
let step = SnapshotPathStep(
selectedIndex: selectedIndex,
childFingerprints: childFingerprints
childFingerprints: childFingerprints,
childTopologyAt: { index in
guard childElements.indices.contains(index) else { return nil }
return AXTree.walkChildren(of: childElements[index])
.map(AXTree.fingerprint(of:))
}
)
else { return nil }
return path + [step]
@@ -72,31 +72,69 @@ struct SnapshotElementHandle: Sendable, Equatable {
}
/// Immutable topology evidence for one parent→child hop. An ordinal alone is
/// never enough: every sibling fingerprint and their order must still match,
/// and the selected fingerprint must be unique among those siblings.
/// never enough: every sibling fingerprint and their order must still match.
/// A duplicate featureless wrapper may be selected only when its ordered direct
/// child topology is unique among the otherwise-identical siblings.
struct SnapshotPathStep: Sendable, Hashable {
let selectedIndex: Int
let childFingerprints: [ElementFingerprint]
private let selectedChildTopology: [ElementFingerprint]?
init?(selectedIndex: Int, childFingerprints: [ElementFingerprint]) {
init?(
selectedIndex: Int,
childFingerprints: [ElementFingerprint],
childTopologyAt: ((Int) -> [ElementFingerprint]?)? = nil
) {
guard childFingerprints.indices.contains(selectedIndex) else { return nil }
let selected = childFingerprints[selectedIndex]
guard childFingerprints.filter({ selected.matches($0) }).count == 1 else {
return nil
let matchingIndices = childFingerprints.indices.filter {
selected.matches(childFingerprints[$0])
}
if matchingIndices.count == 1 {
selectedChildTopology = nil
} else {
guard let childTopologyAt,
let selectedTopology = childTopologyAt(selectedIndex)
else { return nil }
var topologyMatches = 0
for index in matchingIndices {
let topology: [ElementFingerprint]?
if index == selectedIndex {
topology = selectedTopology
} else {
topology = childTopologyAt(index)
}
guard let topology else { return nil }
if topology == selectedTopology { topologyMatches += 1 }
}
guard topologyMatches == 1 else { return nil }
selectedChildTopology = selectedTopology
}
self.selectedIndex = selectedIndex
self.childFingerprints = childFingerprints
}
func selectedIndex(in currentFingerprints: [ElementFingerprint]) -> Int? {
guard childFingerprints == currentFingerprints,
currentFingerprints.indices.contains(selectedIndex)
else { return nil }
let selected = currentFingerprints[selectedIndex]
guard currentFingerprints.filter({ selected.matches($0) }).count == 1 else {
return nil
func selectedIndex(
in currentFingerprints: [ElementFingerprint],
childTopologyAt: ((Int) -> [ElementFingerprint]?)? = nil
) -> Int? {
guard childFingerprints == currentFingerprints else { return nil }
let selected = childFingerprints[selectedIndex]
let matchingIndices = currentFingerprints.indices.filter {
selected.matches(currentFingerprints[$0])
}
return selectedIndex
guard let selectedChildTopology else {
return matchingIndices.count == 1 ? matchingIndices[0] : nil
}
guard let childTopologyAt else { return nil }
var topologyMatches: [Int] = []
for index in matchingIndices {
guard let topology = childTopologyAt(index) else { return nil }
if topology == selectedChildTopology {
topologyMatches.append(index)
}
}
return topologyMatches.count == 1 ? topologyMatches[0] : nil
}
}
@@ -13,7 +13,7 @@ final class AXTreePublicationIntegrationTests: XCTestCase {
private static let fixtureStopPath = "CC_HAHA_AX_PUBLICATION_STOP"
private static let fixtureTitle = "CC_HAHA_AX_PUBLICATION_TITLE"
func testPublishedDescriptionOnlyControlClicksImmediatelyAndRejectsOldGeneration() async throws {
func testPublishedControlBelowDuplicateAncestorClicksImmediatelyAndRejectsOldGeneration() async throws {
if ProcessInfo.processInfo.environment[Self.fixtureFlag] == "1" {
try await runFixtureProcess()
return
@@ -129,19 +129,35 @@ final class AXTreePublicationIntegrationTests: XCTestCase {
defer: false
)
window.title = title
// TextEdit exposes these toolbar checkboxes with nil title/identifier;
// their AX descriptions are the only immutable semantic distinction.
let controls = ["Bold", "Italic", "Underline"].map { label -> NSButton in
// TextEdit exposes formatting and alignment segments as two sibling
// AXGroups whose own fingerprints are identical. Their description-only
// children are the first semantic evidence that distinguishes the paths.
let formattingControls = ["Bold", "Italic", "Underline"].map { label -> NSButton in
let button = NSButton(checkboxWithTitle: "", target: nil, action: nil)
button.setAccessibilityLabel(label)
return button
}
let stack = NSStackView(views: controls)
stack.frame = NSRect(x: 20, y: 60, width: 380, height: 60)
stack.orientation = .horizontal
stack.spacing = 12
let alignmentControls = ["Align Left", "Align Center", "Align Right"].map {
label -> NSButton in
let button = NSButton(checkboxWithTitle: "", target: nil, action: nil)
button.setAccessibilityLabel(label)
return button
}
let formattingGroup = NSStackView(views: formattingControls)
formattingGroup.frame = NSRect(x: 20, y: 60, width: 180, height: 60)
formattingGroup.orientation = .horizontal
formattingGroup.spacing = 12
formattingGroup.setAccessibilityElement(true)
formattingGroup.setAccessibilityRole(.group)
let alignmentGroup = NSStackView(views: alignmentControls)
alignmentGroup.frame = NSRect(x: 220, y: 60, width: 180, height: 60)
alignmentGroup.orientation = .horizontal
alignmentGroup.spacing = 12
alignmentGroup.setAccessibilityElement(true)
alignmentGroup.setAccessibilityRole(.group)
let content = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 180))
content.addSubview(stack)
content.addSubview(formattingGroup)
content.addSubview(alignmentGroup)
window.contentView = content
window.makeKeyAndOrderFront(nil)
app.activate()
@@ -108,6 +108,77 @@ final class ElementFingerprintTests: XCTestCase {
)
}
func testPathStepUsesUniqueChildTopologyForDuplicateAncestor() throws {
let group = ElementFingerprint(
role: "AXGroup",
subrole: nil,
identifier: nil,
title: nil,
valueKind: nil
)
let formatting = ["Bold", "Italic", "Underline"].map { label in
ElementFingerprint(
role: "AXCheckBox",
subrole: "AXSegment",
identifier: nil,
title: nil,
label: label,
valueKind: "boolean"
)
}
let alignment = ["Align Left", "Align Center", "Align Right"].map { label in
ElementFingerprint(
role: "AXCheckBox",
subrole: "AXSegment",
identifier: nil,
title: nil,
label: label,
valueKind: "boolean"
)
}
let publishedTopologies = [formatting, alignment]
let step = try XCTUnwrap(
SnapshotPathStep(
selectedIndex: 0,
childFingerprints: [group, group],
childTopologyAt: { publishedTopologies[$0] }
)
)
XCTAssertEqual(
step.selectedIndex(
in: [group, group],
childTopologyAt: { [alignment, formatting][$0] }
),
1
)
XCTAssertNil(
step.selectedIndex(
in: [group, group],
childTopologyAt: { _ in formatting }
)
)
}
func testPathStepRejectsDuplicateAncestorWithIdenticalChildTopology() {
let group = ElementFingerprint(
role: "AXGroup",
subrole: nil,
identifier: nil,
title: nil,
valueKind: nil
)
let formatting = [button("bold"), button("italic")]
XCTAssertNil(
SnapshotPathStep(
selectedIndex: 0,
childFingerprints: [group, group],
childTopologyAt: { _ in formatting }
)
)
}
func testDescriptionOnlySiblingControlsRemainUniquelyAddressable() throws {
let controls = ["Bold", "Italic", "Underline"].map { label in
ElementFingerprint(