Files
claude-code-haha/native/cu-helper/Tests/CuHelperTests/NativeMethodContractFixture.swift
T
程序员阿江(Relakkes) 56c6a9aa8e feat(computer-use): align native app automation with Codex
Add a persistent isolated JavaScript worker for native app actions and batch
known operations without a model round trip between each input. Preserve
per-cell context, native errors, screenshot coordinates, and image types.

Align macOS gesture, key, inventory, capture, scroll, and clipboard behavior;
include a signed native receiver fixture and compiled sidecar regression tests.
Keep the Windows pixel route and fix cancellation with session-owned mouse
cleanup, lock revalidation, and portable signing-fixture tests.
2026-09-10 03:34:39 +08:00

210 lines
9.6 KiB
Swift

import AppKit
#if !NATIVE_CONTRACT_STANDALONE
@testable import cc_haha_computer_use
#endif
/// The same disposable receiver is compiled standalone for official CUA and
/// hosted by the XCTest fixture child for the local native router.
@MainActor
enum NativeMethodContractFixture {
static func run(root: URL) throws {
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
// Native paste is delivered through the normal Command-V responder
// chain. A standalone NSApplication has no nib-provided Edit menu.
let mainMenu = NSMenu()
let editItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "")
let editMenu = NSMenu(title: "Edit")
editMenu.autoenablesItems = false
for (title, selector, key) in [
("Cut", #selector(NSText.cut(_:)), "x"),
("Copy", #selector(NSText.copy(_:)), "c"),
("Paste", #selector(NSText.paste(_:)), "v"),
("Select All", #selector(NSText.selectAll(_:)), "a"),
] { editMenu.addItem(withTitle: title, action: selector, keyEquivalent: key) }
editItem.submenu = editMenu
mainMenu.addItem(editItem)
app.mainMenu = mainMenu
app.finishLaunching()
let window = ContractWindow(contentRect: NSRect(x: 120, y: 160, width: 760, height: 520), styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.delegate = window
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .canJoinAllApplications]
window.title = "Native method contract fixture"
let content = NSView(frame: window.contentView!.bounds)
let editor = ContractEditor(frame: NSRect(x: 20, y: 310, width: 720, height: 170))
editor.isRichText = true
editor.font = NSFont.systemFont(ofSize: 18)
editor.string = "alpha beta gamma"
editor.setAccessibilityLabel("Contract editor")
content.addSubview(editor)
let scroll = ContractScrollView(frame: NSRect(x: 20, y: 70, width: 720, height: 210))
// Native AX page increments depend on the visible clip size. Pin the
// receiver geometry instead of inheriting dynamic system scrollbar style.
scroll.scrollerStyle = .legacy
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.setAccessibilityLabel("Contract scroll")
let document = NSTextView(frame: NSRect(x: 0, y: 0, width: 1400, height: 1800))
document.isEditable = false
document.font = NSFont.systemFont(ofSize: 18)
document.string = (1...70).map { "Disposable row \($0)" }.joined(separator: "\n")
document.setAccessibilityLabel("Contract rows")
scroll.documentView = document
content.addSubview(scroll)
let action = ContractSecondaryView(frame: NSRect(x: 20, y: 15, width: 400, height: 40))
action.setAccessibilityElement(true)
action.setAccessibilityRole(.button)
action.setAccessibilityLabel("Contract secondary action")
content.addSubview(action)
window.contentView = content
window.orderFrontRegardless()
window.makeMain()
window.makeFirstResponder(editor)
let ready = root.appendingPathComponent("ready")
let receipt = root.appendingPathComponent("contract.json")
var previous: Data?
let timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
MainActor.assumeIsolated {
let selection = editor.selectedRange()
let value: [String: Any] = [
"text": editor.string,
"selectionLocation": selection.location,
"selectionLength": selection.length,
"scrollY": scroll.contentView.bounds.origin.y,
"scrollX": scroll.contentView.bounds.origin.x,
"wheel": scroll.events,
"secondaryCount": action.count,
"visible": window.isVisible,
"hidden": app.isHidden,
"windowNumber": window.windowNumber,
"windowFrame": NSStringFromRect(window.frame),
"onActiveSpace": window.isOnActiveSpace,
"miniaturized": window.isMiniaturized,
"occlusionState": window.occlusionState.rawValue,
"windowEvents": window.events,
"clipSize": NSStringFromSize(scroll.contentView.bounds.size),
"scrollerStyle": scroll.scrollerStyle.rawValue,
"editorEvents": editor.events,
]
if let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), data != previous {
try? data.write(to: receipt, options: .atomic)
previous = data
}
if FileManager.default.fileExists(atPath: root.appendingPathComponent("stop").path) { exit(0) }
}
}
defer { timer.invalidate() }
// NSWorkspace/open -g finishes its background-launch policy after
// main() starts. Publish readiness only after that launch boundary.
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
MainActor.assumeIsolated {
window.orderFrontRegardless()
try? Data(String(ProcessInfo.processInfo.processIdentifier).utf8).write(to: ready)
}
}
// A bounded receiver process never survives a failed parent test.
Timer.scheduledTimer(withTimeInterval: 600, repeats: false) { _ in exit(0) }
app.run()
}
}
@MainActor
private final class ContractWindow: NSWindow, NSWindowDelegate {
var events: [[String: Any]] = []
private func record(_ kind: String, includeStack: Bool = false) {
var event: [String: Any] = [
"kind": kind, "uptime": ProcessInfo.processInfo.systemUptime,
"visible": isVisible, "miniaturized": isMiniaturized,
"onActiveSpace": isOnActiveSpace,
]
if let current = NSApplication.shared.currentEvent {
event["eventType"] = current.type.rawValue
if [.keyDown, .keyUp, .flagsChanged].contains(current.type) {
event["keyCode"] = current.keyCode
}
event["eventMarker"] = current.cgEvent?.getIntegerValueField(.eventSourceUserData)
}
if includeStack { event["stack"] = Array(Thread.callStackSymbols.prefix(12)) }
events.append(event)
if events.count > 40 { events.removeFirst(events.count - 40) }
}
override func orderOut(_ sender: Any?) {
record("orderOut", includeStack: true)
super.orderOut(sender)
}
override func close() {
record("close", includeStack: true)
super.close()
}
override func miniaturize(_ sender: Any?) {
record("miniaturize", includeStack: true)
super.miniaturize(sender)
}
func windowWillClose(_ notification: Notification) { record("willClose") }
func windowDidMiniaturize(_ notification: Notification) { record("didMiniaturize") }
func windowDidDeminiaturize(_ notification: Notification) { record("didDeminiaturize") }
func windowDidChangeOcclusionState(_ notification: Notification) { record("occlusionChanged") }
func windowDidBecomeKey(_ notification: Notification) { record("becameKey") }
func windowDidResignKey(_ notification: Notification) { record("resignedKey") }
}
@MainActor
private final class ContractScrollView: NSScrollView {
var events: [[String: Any]] = []
override func scrollWheel(with event: NSEvent) {
events.append(["x": event.scrollingDeltaX, "y": event.scrollingDeltaY, "precise": event.hasPreciseScrollingDeltas])
super.scrollWheel(with: event)
}
}
@MainActor
private final class ContractEditor: NSTextView {
var events: [[String: Any]] = []
override func paste(_ sender: Any?) {
if let name = ProcessInfo.processInfo.environment["CC_HAHA_METHOD_FIXTURE_PASTEBOARD"] {
// The real menu/responder chain consumes promised data from the
// test-owned pasteboard, without consulting the user's clipboard.
_ = readSelection(from: NSPasteboard(name: NSPasteboard.Name(name)))
} else {
super.paste(sender)
}
}
override func keyDown(with event: NSEvent) {
#if !NATIVE_CONTRACT_STANDALONE
// The test app is a receiver, never a destination for physical typing.
// Native generated keys are marked; AX writes need no keyboard event.
guard event.cgEvent?.getIntegerValueField(.eventSourceUserData) == HelperEventMarker.value else { return }
#endif
events.append(["kind": "keyDown", "keyCode": Int(event.keyCode), "flags": event.modifierFlags.rawValue])
super.keyDown(with: event)
}
override func didChangeText() {
super.didChangeText()
events.append(["kind": "changed", "text": string])
}
}
@MainActor
private final class ContractSecondaryView: NSView {
var count = 0
override func accessibilityPerformShowMenu() -> Bool {
count += 1
needsDisplay = true
return true
}
override func draw(_ dirtyRect: NSRect) {
NSColor.windowBackgroundColor.setFill()
dirtyRect.fill()
("Secondary actions received: \(count)" as NSString).draw(at: NSPoint(x: 5, y: 8), withAttributes: [.font: NSFont.systemFont(ofSize: 16)])
}
}
#if NATIVE_CONTRACT_STANDALONE
@main
struct NativeContractMain {
@MainActor static func main() throws {
try NativeMethodContractFixture.run(root: URL(fileURLWithPath: CommandLine.arguments[1]))
}
}
#endif