mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(computer-use): resolve duplicate AX groups
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user