mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
test(computer-use): remove desktop-interacting native fixtures
This commit is contained in:
@@ -116,12 +116,12 @@ deadline; an in-flight native command must settle before the runner returns.
|
||||
Standalone semantic tools retain direct-call compatibility, but they and
|
||||
`sequence` are not advertised by default. Windows retains its pixel tool face.
|
||||
|
||||
`AXTreePublicationIntegrationTests` exercises twelve consecutive zero/one-pixel
|
||||
drags against a disposable native app with a receiver-side gesture counter,
|
||||
without observations between gestures. Its AX/Screen Recording prerequisites
|
||||
are explicit skips when unavailable. Factory tests prove the exact event list;
|
||||
the receiving-app test proves delivery of complete gestures even if AppKit
|
||||
coalesces intermediate motion.
|
||||
The ordinary Swift test suite uses event factories and injected receivers to
|
||||
check input construction and routing without launching fixture apps, opening
|
||||
windows, or driving the macOS desktop. The former native receiver integration
|
||||
suite was removed because it interacted with the developer's desktop whenever
|
||||
Accessibility and Screen Recording permissions were already available.
|
||||
These tests do not prove delivery to a real receiving app.
|
||||
|
||||
The identity diagnostic added to `Injection.validateAuthorizedTarget` is
|
||||
DEBUG-only and records the exact target/current values used by strict comparison.
|
||||
@@ -144,8 +144,9 @@ Matching these contracts does not establish complete Codex compatibility or
|
||||
Townscaper/Blender task success. The persistent JS native App facade is present,
|
||||
but browser-tab/DOM providers and the official runtime's general Node facilities
|
||||
are not. Tests that run the actual sandboxed worker with simulated native tools
|
||||
establish persistence, isolation and output behavior. The native receiver test
|
||||
establishes gesture delivery. Neither proves parity across real model tasks.
|
||||
establish persistence, isolation and output behavior. Real receiving-app gesture
|
||||
delivery requires a separate manual check. These tests do not prove parity
|
||||
across real model tasks.
|
||||
|
||||
Focus acquisition, AX fallbacks, key synthesis, coordinate transforms, capture,
|
||||
and gesture delivery must each be verified at their actual boundary. Do not
|
||||
|
||||
@@ -1,818 +0,0 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import cc_haha_computer_use
|
||||
|
||||
@MainActor
|
||||
final class AXTreePublicationIntegrationTests: XCTestCase {
|
||||
private enum FixtureError: Error {
|
||||
case timedOut
|
||||
}
|
||||
|
||||
private static let fixtureFlag = "CC_HAHA_AX_PUBLICATION_FIXTURE"
|
||||
private static let fixtureReadyPath = "CC_HAHA_AX_PUBLICATION_READY"
|
||||
private static let fixtureStopPath = "CC_HAHA_AX_PUBLICATION_STOP"
|
||||
private static let fixtureTitle = "CC_HAHA_AX_PUBLICATION_TITLE"
|
||||
private static let fixtureMismatchedTitle = "CC_HAHA_AX_PUBLICATION_MISMATCHED_TITLE"
|
||||
private static let fixtureGesturePath = "CC_HAHA_AX_PUBLICATION_GESTURES"
|
||||
private static let fixtureRegularActivation = "CC_HAHA_AX_PUBLICATION_REGULAR_ACTIVATION"
|
||||
private static let fixtureWideWindow = "CC_HAHA_AX_PUBLICATION_WIDE_WINDOW"
|
||||
private static let fixtureMethods = "CC_HAHA_AX_PUBLICATION_METHOD_CONTRACT"
|
||||
|
||||
func testPublishedControlBelowDuplicateAncestorClicksImmediatelyAndRejectsOldGeneration() async throws {
|
||||
try await verifyPublishedControl(mismatchedWindowTitle: false)
|
||||
}
|
||||
|
||||
func testChromeStyleAXTitleCanDifferFromWindowServerTitleWithoutDisablingActions() async throws {
|
||||
try await verifyPublishedControl(mismatchedWindowTitle: true)
|
||||
}
|
||||
|
||||
func testConsecutiveZeroAndOnePixelDragsReachTheAppWithoutIntermediateObservations() async throws {
|
||||
try await verifyPublishedControl(mismatchedWindowTitle: false, exerciseDrags: true)
|
||||
}
|
||||
|
||||
func testOfficialKeyboardAliasesAndMacroReachTheNativeReceiver() async throws {
|
||||
try await verifyPublishedControl(mismatchedWindowTitle: false, exerciseKeys: true)
|
||||
}
|
||||
|
||||
func testWideWindowUsesOfficialScreenshotSizeAndKeepsDragCoordinatesAligned() async throws {
|
||||
try await verifyPublishedControl(mismatchedWindowTitle: false, exerciseDrags: true, wideWindow: true)
|
||||
}
|
||||
|
||||
func testVisibleCursorSurvivesRepeatedClicksAndTracksExposedBackgroundWindow() async throws {
|
||||
try await verifyPublishedControl(mismatchedWindowTitle: false, exerciseVisualClicks: true)
|
||||
}
|
||||
|
||||
func testTextScrollAndSecondaryMethodsReachTheSameOfficialReceiver() async throws {
|
||||
if ProcessInfo.processInfo.environment[Self.fixtureFlag] == "1" {
|
||||
try await runFixtureProcess(mismatchedWindowTitle: false, regularActivation: true, wideWindow: false)
|
||||
exit(0)
|
||||
}
|
||||
try XCTSkipUnless(AXIsProcessTrusted(), "Native contract receiver requires Accessibility permission")
|
||||
try XCTSkipUnless(Capture.hasScreenRecordingPermission(), "Coordinate scroll requires Screen Recording permission")
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent("cc-haha-method-contract-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let ready = root.appendingPathComponent("ready")
|
||||
let stop = root.appendingPathComponent("stop")
|
||||
let receiptPath = root.appendingPathComponent("contract.json")
|
||||
let pasteboardName = "cc-haha-method-contract-\(UUID().uuidString)"
|
||||
let pasteboard = NSPasteboard(name: NSPasteboard.Name(pasteboardName))
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("disposable original clipboard", forType: .string)
|
||||
defer { pasteboard.releaseGlobally() }
|
||||
let app = try makeFixtureApp(in: root)
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.arguments = Array(CommandLine.arguments.dropFirst())
|
||||
configuration.environment = ProcessInfo.processInfo.environment.merging([
|
||||
Self.fixtureFlag: "1", Self.fixtureMethods: "1",
|
||||
Self.fixtureReadyPath: ready.path, Self.fixtureStopPath: stop.path,
|
||||
Self.fixtureTitle: "Native method contract fixture",
|
||||
"CC_HAHA_METHOD_FIXTURE_PASTEBOARD": pasteboardName,
|
||||
]) { _, value in value }
|
||||
configuration.activates = false
|
||||
configuration.createsNewApplicationInstance = true
|
||||
let process = try await NSWorkspace.shared.openApplication(at: app, configuration: configuration)
|
||||
defer {
|
||||
FileManager.default.createFile(atPath: stop.path, contents: Data())
|
||||
if !process.isTerminated { process.terminate() }
|
||||
}
|
||||
try await waitUntil(description: "method fixture ready") { FileManager.default.fileExists(atPath: ready.path) && FileManager.default.fileExists(atPath: receiptPath.path) }
|
||||
let pid = process.processIdentifier
|
||||
defer { AXTree.invalidate(pid: pid) }
|
||||
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
||||
_ = monitor.startAndWait()
|
||||
defer { monitor.stop() }
|
||||
let cursor = VirtualCursor(headless: false)
|
||||
defer { cursor.hide() }
|
||||
let router = CommandRouter(cursor: cursor, capabilities: Capabilities(headless: false), inputMonitor: monitor,
|
||||
pasteExecutor: { pid, text, format in
|
||||
try await AXAction.pasteText(pid: pid, text, format: format, lease: ClipboardLease(pasteboard: pasteboard))
|
||||
})
|
||||
func state() async throws -> AXTree.Result { try await AXTree.appState(pid: pid, disableDiff: true) }
|
||||
func receipt() -> [String: Any] {
|
||||
guard let data = try? Data(contentsOf: receiptPath),
|
||||
let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return value
|
||||
}
|
||||
var coordinateCaptureDiagnostic = "not captured"
|
||||
func diagnostic() -> String {
|
||||
let windows = (CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]] ?? [])
|
||||
.filter { $0[kCGWindowOwnerPID as String] as? Int == Int(pid) }
|
||||
.map { ["id": $0[kCGWindowNumber as String] ?? "nil", "bounds": $0[kCGWindowBounds as String] ?? "nil", "onScreen": $0[kCGWindowIsOnscreen as String] ?? "nil", "layer": $0[kCGWindowLayer as String] ?? "nil"] }
|
||||
return "snapshot=\(String(describing: AXTree.snapshotEvidence(pid: pid))) capture=\(coordinateCaptureDiagnostic) windows=\(windows) receipt=\(receipt())"
|
||||
}
|
||||
var observed = try await state()
|
||||
var editor = try publishedHandle(label: "Contract editor", state: observed).0
|
||||
func action(_ command: String, _ args: [String: JSONValue]) async throws {
|
||||
do {
|
||||
_ = try await router.handle(cmd: command, payload: .object(args.merging(["pid": .int(Int(pid))]) { current, _ in current }))
|
||||
} catch {
|
||||
print("[native-six-method-failure] command=\(command) \(diagnostic())")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
try await action("set_value", ["index": .string(editor.rawValue), "value": .string("alpha beta gamma")])
|
||||
observed = try await state()
|
||||
editor = try publishedHandle(label: "Contract editor", state: observed).0
|
||||
try await action("select_text", ["index": .string(editor.rawValue), "text": .string("beta")])
|
||||
try await waitUntil(description: "beta selected") { receipt()["selectionLocation"] as? Int == 6 && receipt()["selectionLength"] as? Int == 4 }
|
||||
try await action("type_text", ["text": .string("typed")])
|
||||
try await waitUntil(description: "selection replaced by typeText", diagnostic: { "\(receipt())" }) { receipt()["text"] as? String == "alpha typed gamma" }
|
||||
observed = try await state()
|
||||
editor = try publishedHandle(label: "Contract editor", state: observed).0
|
||||
try await action("select_text", ["index": .string(editor.rawValue), "text": .string("typed")])
|
||||
try await action("paste", ["text": .string("pasted"), "format": .string("text")])
|
||||
try await waitUntil(description: "selection replaced by paste") { receipt()["text"] as? String == "alpha pasted gamma" }
|
||||
XCTAssertEqual(pasteboard.string(forType: .string), "disposable original clipboard")
|
||||
XCTAssertEqual(ClipboardPasteReceipt.lastDiagnostic?.status, "completed")
|
||||
XCTAssertEqual(ClipboardPasteReceipt.lastDiagnostic?.dataSupplied, true)
|
||||
XCTAssertEqual(ClipboardPasteReceipt.lastDiagnostic?.restored, true)
|
||||
XCTAssertEqual(receipt()["selectionLocation"] as? Int, 12)
|
||||
XCTAssertEqual(receipt()["selectionLength"] as? Int, 0)
|
||||
observed = try await state()
|
||||
let scroll = try publishedHandle(label: "Contract scroll", state: observed).0
|
||||
var wheelCount = 0
|
||||
func scrollAndReceive(_ args: [String: JSONValue], x: Double? = nil, y: Double? = nil) async throws {
|
||||
try await action("scroll", args)
|
||||
wheelCount += 1
|
||||
try await waitUntil(description: "scroll event \(wheelCount)", diagnostic: diagnostic) {
|
||||
let current = receipt()
|
||||
guard (current["wheel"] as? [[String: Any]])?.count == wheelCount else { return false }
|
||||
if let x, abs((current["scrollX"] as? Double ?? -.infinity) - x) > 0.01 { return false }
|
||||
if let y, abs((current["scrollY"] as? Double ?? -.infinity) - y) > 0.01 { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
try await scrollAndReceive(["index": .string(scroll.rawValue), "direction": .string("down"), "pages": .double(0.5)], y: 105)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollY"] as? Double), 105, accuracy: 0.01)
|
||||
try await scrollAndReceive(["index": .string(scroll.rawValue), "direction": .string("down"), "pages": .double(1.5)], y: 420)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollY"] as? Double), 420, accuracy: 0.01)
|
||||
let coordinateState = try await router.handle(cmd: "get_app_state", payload: .object(["pid": .int(Int(pid)), "disableDiff": .bool(true)]))
|
||||
if case .object(let stateObject) = coordinateState,
|
||||
case .object(let screenshot) = stateObject["screenshot"] {
|
||||
coordinateCaptureDiagnostic = "\(screenshot.filter { $0.key != "base64" })"
|
||||
}
|
||||
try await scrollAndReceive(["x": .int(380), "y": .int(377), "direction": .string("down"), "pages": .double(0.5)], y: 696)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollY"] as? Double), 696, accuracy: 0.01)
|
||||
try await scrollAndReceive(["x": .int(380), "y": .int(377), "direction": .string("up"), "pages": .double(0.5)], y: 420)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollY"] as? Double), 420, accuracy: 0.01)
|
||||
try await scrollAndReceive(["index": .string(scroll.rawValue), "direction": .string("up"), "pages": .double(0.5)], y: 315)
|
||||
try await scrollAndReceive(["index": .string(scroll.rawValue), "direction": .string("down"), "pages": .double(0.123)], y: 341)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollY"] as? Double), 341, accuracy: 0.01)
|
||||
// Intentional improvement over the inspected official wheelCount=1:
|
||||
// keep the second wheel axis so horizontal scrolling remains usable.
|
||||
try await scrollAndReceive(["index": .string(scroll.rawValue), "direction": .string("right"), "pages": .double(0.5)], x: 360)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollX"] as? Double), 360, accuracy: 0.01)
|
||||
try await scrollAndReceive(["index": .string(scroll.rawValue), "direction": .string("left"), "pages": .double(0.5)], x: 0)
|
||||
XCTAssertEqual(try XCTUnwrap(receipt()["scrollX"] as? Double), 0, accuracy: 0.01)
|
||||
try await action("perform_secondary_action", ["index": .string(scroll.rawValue), "action": .string("Scroll Down")])
|
||||
try await waitUntil(description: "secondary AX page scroll", diagnostic: diagnostic) {
|
||||
receipt()["scrollY"] as? Double == 524
|
||||
}
|
||||
let wheel = try XCTUnwrap(receipt()["wheel"] as? [[String: Any]])
|
||||
XCTAssertEqual(wheel.compactMap { $0["y"] as? Double }, [-105, -315, -276, 276, 105, -26, 0, 0])
|
||||
XCTAssertEqual(wheel.compactMap { $0["x"] as? Double }, [0, 0, 0, 0, 0, 0, -360, 360])
|
||||
XCTAssertTrue(wheel.allSatisfy { $0["precise"] as? Bool == true })
|
||||
print("[native-six-method-smoke] \(receipt())")
|
||||
FileManager.default.createFile(atPath: stop.path, contents: Data())
|
||||
try await waitUntil(description: "method fixture exit") { process.isTerminated }
|
||||
}
|
||||
|
||||
private func verifyPublishedControl(mismatchedWindowTitle: Bool, exerciseDrags: Bool = false, exerciseKeys: Bool = false, wideWindow: Bool = false, exerciseVisualClicks: Bool = false) async throws {
|
||||
if ProcessInfo.processInfo.environment[Self.fixtureFlag] == "1" {
|
||||
try await runFixtureProcess(
|
||||
mismatchedWindowTitle: ProcessInfo.processInfo.environment[Self.fixtureMismatchedTitle] == "1",
|
||||
regularActivation: ProcessInfo.processInfo.environment[Self.fixtureRegularActivation] == "1",
|
||||
wideWindow: ProcessInfo.processInfo.environment[Self.fixtureWideWindow] == "1"
|
||||
)
|
||||
// This process is a disposable UI fixture, not another suite run.
|
||||
exit(0)
|
||||
}
|
||||
try XCTSkipUnless(
|
||||
AXIsProcessTrusted(),
|
||||
"Live AX publication requires Accessibility permission for the test runner"
|
||||
)
|
||||
if mismatchedWindowTitle || exerciseDrags || exerciseVisualClicks {
|
||||
try XCTSkipUnless(
|
||||
Capture.hasScreenRecordingPermission(),
|
||||
"Coordinate publication requires Screen Recording permission for the test runner"
|
||||
)
|
||||
}
|
||||
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cc-haha-ax-publication-\(UUID().uuidString)")
|
||||
let ready = root.appendingPathComponent("ready")
|
||||
let stop = root.appendingPathComponent("stop")
|
||||
let gestures = root.appendingPathComponent("gestures.json")
|
||||
let title = "Computer Use snapshot publication \(UUID().uuidString)"
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
// AXTree requires a proven process lifetime, and querying the test runner's
|
||||
// own AX tree cannot exercise a cross-process action. Relaunch this same
|
||||
// test as a temporary app bundle so the production AX/WindowServer path
|
||||
// supplies every snapshot field instead of a hand-written fixture.
|
||||
let fixtureApp = try makeFixtureApp(in: root)
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.arguments = Array(CommandLine.arguments.dropFirst())
|
||||
configuration.environment = ProcessInfo.processInfo.environment.merging([
|
||||
Self.fixtureFlag: "1",
|
||||
Self.fixtureReadyPath: ready.path,
|
||||
Self.fixtureStopPath: stop.path,
|
||||
Self.fixtureTitle: title,
|
||||
Self.fixtureMismatchedTitle: mismatchedWindowTitle ? "1" : "0",
|
||||
Self.fixtureGesturePath: gestures.path,
|
||||
// A full-suite child enters the first test, so fixture modes must
|
||||
// travel with this launch rather than that test method's defaults.
|
||||
Self.fixtureRegularActivation: exerciseKeys || exerciseVisualClicks ? "1" : "0",
|
||||
Self.fixtureWideWindow: wideWindow ? "1" : "0",
|
||||
]) { _, fixture in fixture }
|
||||
configuration.activates = false
|
||||
configuration.createsNewApplicationInstance = true
|
||||
let process = try await NSWorkspace.shared.openApplication(
|
||||
at: fixtureApp,
|
||||
configuration: configuration
|
||||
)
|
||||
defer {
|
||||
FileManager.default.createFile(atPath: stop.path, contents: Data())
|
||||
if !process.isTerminated { process.terminate() }
|
||||
}
|
||||
|
||||
try await waitUntil(description: "fixture launch") { FileManager.default.fileExists(atPath: ready.path) }
|
||||
let pid = process.processIdentifier
|
||||
defer { AXTree.invalidate(pid: pid) }
|
||||
let identities = FixtureIdentityDiagnostics(pid: pid)
|
||||
defer { identities.finish() }
|
||||
|
||||
let state = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let windowID = try XCTUnwrap(AXTree.snapshotEvidence(pid: pid)?.keyWindowID)
|
||||
XCTAssertEqual(AXTree.currentKeyWindowID(pid: pid), windowID)
|
||||
let (handle, line) = try publishedHandle(label: "Bold", state: state)
|
||||
XCTAssertEqual(
|
||||
AXTree.record(pid: pid, index: handle.index)?.role,
|
||||
kAXCheckBoxRole as String
|
||||
)
|
||||
XCTAssertTrue(line.contains("Value: 0"), line)
|
||||
let inputMonitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
||||
_ = inputMonitor.startAndWait()
|
||||
defer { inputMonitor.stop() }
|
||||
let cursor = VirtualCursor(headless: false)
|
||||
defer { cursor.hide() }
|
||||
let router = CommandRouter(
|
||||
cursor: cursor,
|
||||
capabilities: Capabilities(headless: false),
|
||||
inputMonitor: inputMonitor
|
||||
)
|
||||
identities.stage = "initial indexed click"
|
||||
let click = try await router.handle(
|
||||
cmd: "click",
|
||||
payload: .object([
|
||||
"pid": .int(Int(pid)),
|
||||
"index": .string(handle.rawValue),
|
||||
])
|
||||
)
|
||||
XCTAssertEqual(click, .bool(true))
|
||||
|
||||
let clickedState = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let (_, clickedLine) = try publishedHandle(label: "Bold", state: clickedState)
|
||||
XCTAssertTrue(clickedLine.contains("Value: 1"), clickedLine)
|
||||
|
||||
if exerciseVisualClicks {
|
||||
try await verifyVisibleClicks(cursor: cursor, router: router, process: process, gestures: gestures)
|
||||
}
|
||||
|
||||
if exerciseKeys {
|
||||
let (canvasHandle, _) = try publishedHandle(label: "Drag fixture", state: clickedState)
|
||||
var phase = "canvas click"
|
||||
do {
|
||||
_ = try await router.handle(cmd: "click", payload: .object([
|
||||
"pid": .int(Int(pid)), "index": .string(canvasHandle.rawValue),
|
||||
]))
|
||||
phase = "keyboard macro"
|
||||
_ = try await router.handle(cmd: "press_key", payload: .object([
|
||||
"pid": .int(Int(pid)),
|
||||
"key": .string("Control_L+a Super_R+b A question Delete BackSpace"),
|
||||
]))
|
||||
} catch {
|
||||
let windows = (CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID) as? [[String: Any]] ?? [])
|
||||
.filter { $0[kCGWindowOwnerPID as String] as? Int == Int(pid) }
|
||||
.map { ["id": $0[kCGWindowNumber as String] ?? "nil", "bounds": $0[kCGWindowBounds as String] ?? "nil", "onScreen": $0[kCGWindowIsOnscreen as String] ?? "nil", "layer": $0[kCGWindowLayer as String] ?? "nil"] }
|
||||
let frame = AXTree.record(pid: pid, index: canvasHandle.index)?.frameGlobal
|
||||
let receipt = (try? String(contentsOf: gestures, encoding: .utf8)) ?? "no receipt"
|
||||
throw CUError((error as? CUError)?.code ?? "fixture_action_failed",
|
||||
"\(error.localizedDescription) Fixture phase=\(phase), pid=\(pid), terminated=\(process.isTerminated), canvas=\(String(describing: frame)), windows=\(windows), receipt=\(receipt)")
|
||||
}
|
||||
try await waitUntil(description: "six received macro keys") {
|
||||
guard let data = try? Data(contentsOf: gestures),
|
||||
let receipt = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let keys = receipt["keys"] as? [[String: Any]] else { return false }
|
||||
return keys.count == 6
|
||||
}
|
||||
let receipt = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(contentsOf: gestures)) as? [String: Any])
|
||||
let keys = try XCTUnwrap(receipt["keys"] as? [[String: Any]])
|
||||
XCTAssertEqual(keys.compactMap { $0["keyCode"] as? Int }, [0, 11, 0, 44, 117, 51])
|
||||
XCTAssertEqual(keys.compactMap { $0["modifiers"] as? UInt }, [262144, 1048576, 131072, 131072, 0, 0])
|
||||
let discovered = try await Apps.listApps()
|
||||
XCTAssertEqual(discovered.first { $0.id == process.bundleIdentifier }?.isRunning, true)
|
||||
print("[native-key-smoke] six-key macro received, inventory entries=\(discovered.count), recent=\(discovered.filter { !$0.isRunning }.count)")
|
||||
}
|
||||
|
||||
if exerciseDrags {
|
||||
let captured = try await router.handle(cmd: "get_app_state", payload: .object([
|
||||
"pid": .int(Int(pid)), "disableDiff": .bool(true),
|
||||
]))
|
||||
let shot = try XCTUnwrap(captured["screenshot"])
|
||||
if wideWindow {
|
||||
XCTAssertEqual(shot["width"]?.asInt, 1397)
|
||||
XCTAssertEqual(shot["height"]?.asInt, 768)
|
||||
XCTAssertEqual(shot["pointWidth"]?.asDouble, 1398)
|
||||
XCTAssertEqual(shot["pointHeight"]?.asDouble, 769)
|
||||
XCTAssertEqual(shot["mimeType"]?.asString, "image/jpeg")
|
||||
XCTAssertEqual(try XCTUnwrap(shot["pixelsPerPoint"]?.asDouble), 768.0 / 769.0, accuracy: 0.000001)
|
||||
let bytes = try XCTUnwrap(Data(base64Encoded: try XCTUnwrap(shot["base64"]?.asString)))
|
||||
XCTAssertEqual(Array(bytes.prefix(2)), [255, 216])
|
||||
}
|
||||
let canvasState = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let (canvasHandle, _) = try publishedHandle(label: "Drag fixture", state: canvasState)
|
||||
let frame = try XCTUnwrap(AXTree.record(pid: pid, index: canvasHandle.index)?.frameGlobal)
|
||||
let x = wideWindow ? 300 : (frame.x + frame.w / 2 - (try XCTUnwrap(shot["originX"]?.asDouble)))
|
||||
* Double(try XCTUnwrap(shot["width"]?.asInt))
|
||||
/ (try XCTUnwrap(shot["pointWidth"]?.asDouble))
|
||||
let y = wideWindow ? 180 : (frame.y + frame.h / 2 - (try XCTUnwrap(shot["originY"]?.asDouble)))
|
||||
* Double(try XCTUnwrap(shot["height"]?.asInt))
|
||||
/ (try XCTUnwrap(shot["pointHeight"]?.asDouble))
|
||||
let started = ContinuousClock.now
|
||||
for index in 0..<12 {
|
||||
// Same observed canvas and viewport throughout. Alternating zero
|
||||
// and one pixel exercises the Townscaper-style gesture without
|
||||
// relying on a browser, a real document, or model decisions.
|
||||
identities.stage = "drag \(index)"
|
||||
do {
|
||||
_ = try await router.handle(cmd: "drag", payload: .object([
|
||||
"pid": .int(Int(pid)),
|
||||
"from": .object(["x": .double(x), "y": .double(y)]),
|
||||
"to": .object(["x": .double(x + Double(index % 2)), "y": .double(y)]),
|
||||
]))
|
||||
} catch {
|
||||
print("[native-drag-smoke] failed step \(index), fixture terminated=\(process.isTerminated), stop=\(FileManager.default.fileExists(atPath: stop.path)), snapshot=\(String(describing: AXTree.snapshotEvidence(pid: pid)?.processIdentity)), live=\(String(describing: AXTree.currentProcessIdentity(pid: pid))), receiver=\((try? String(contentsOf: gestures, encoding: .utf8)) ?? "no receipt")")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
identities.stage = "final observation"
|
||||
let final = try await router.handle(cmd: "get_app_state", payload: .object([
|
||||
"pid": .int(Int(pid)), "disableDiff": .bool(true),
|
||||
]))
|
||||
XCTAssertNotNil(final["screenshot"])
|
||||
try await waitUntil(description: "12 received gestures", diagnostic: {
|
||||
"terminated=\(process.isTerminated) stop=\(FileManager.default.fileExists(atPath: stop.path)) receiver=\((try? String(contentsOf: gestures, encoding: .utf8)) ?? "no gesture receipt")"
|
||||
}) {
|
||||
guard let data = try? Data(contentsOf: gestures),
|
||||
let receipt = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return false }
|
||||
return receipt["completed"] as? Int == 12
|
||||
}
|
||||
let receipt = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: Data(contentsOf: gestures)) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual(receipt["completed"] as? Int, 12)
|
||||
XCTAssertEqual(receipt["drags"] as? Int, 12, "zero-distance gestures must still contain a dragged event")
|
||||
XCTAssertEqual(receipt["unpaired"] as? Int, 0)
|
||||
if wideWindow {
|
||||
let events = try XCTUnwrap(receipt["events"] as? [[String: Any]])
|
||||
let down = try XCTUnwrap(events.first { $0["type"] as? UInt == NSEvent.EventType.leftMouseDown.rawValue })
|
||||
// Measured from the official App.drag on the same window size:
|
||||
// x300/y180 uses a uniform 769/768 inverse fit on both axes.
|
||||
XCTAssertEqual(try XCTUnwrap(down["x"] as? Double), 300.390625, accuracy: 0.000001)
|
||||
XCTAssertEqual(try XCTUnwrap(down["y"] as? Double), 588.765625, accuracy: 0.000001)
|
||||
}
|
||||
#if DEBUG
|
||||
XCTAssertGreaterThan(identities.samples, 0, "the identity experiment must observe real production validation")
|
||||
#endif
|
||||
print("[native-drag-smoke] 12 received gestures + final observation: \(started.duration(to: .now))")
|
||||
}
|
||||
|
||||
if mismatchedWindowTitle {
|
||||
// Exercise the actual state → screenshot → coordinate action path,
|
||||
// using only this disposable fixture window. Previously the image
|
||||
// was returned while its coordinate transform was never recorded.
|
||||
let captured = try await router.handle(cmd: "get_app_state", payload: .object([
|
||||
"pid": .int(Int(pid)), "disableDiff": .bool(true),
|
||||
]))
|
||||
let shot = try XCTUnwrap(captured["screenshot"])
|
||||
XCTAssertEqual(shot["windowID"]?.asInt, Int(windowID))
|
||||
let frame = try XCTUnwrap(AXTree.record(pid: pid, index: handle.index)?.frameGlobal)
|
||||
let x = (frame.x + frame.w / 2 - (try XCTUnwrap(shot["originX"]?.asDouble)))
|
||||
* Double(try XCTUnwrap(shot["width"]?.asInt))
|
||||
/ (try XCTUnwrap(shot["pointWidth"]?.asDouble))
|
||||
let y = (frame.y + frame.h / 2 - (try XCTUnwrap(shot["originY"]?.asDouble)))
|
||||
* Double(try XCTUnwrap(shot["height"]?.asInt))
|
||||
/ (try XCTUnwrap(shot["pointHeight"]?.asDouble))
|
||||
_ = try await router.handle(cmd: "click", payload: .object([
|
||||
"pid": .int(Int(pid)), "x": .double(x), "y": .double(y),
|
||||
]))
|
||||
let coordinateState = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let (coordinateHandle, coordinateLine) = try publishedHandle(label: "Bold", state: coordinateState)
|
||||
XCTAssertTrue(coordinateLine.contains("Value: 0"), coordinateLine)
|
||||
// Restore the checked state for the stale-generation assertion.
|
||||
_ = try await router.handle(cmd: "click", payload: .object([
|
||||
"pid": .int(Int(pid)), "index": .string(coordinateHandle.rawValue),
|
||||
]))
|
||||
}
|
||||
|
||||
AXTree.invalidate(pid: pid)
|
||||
let nextState = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let (nextHandle, _) = try publishedHandle(label: "Bold", state: nextState)
|
||||
XCTAssertNotEqual(nextHandle.snapshotID, handle.snapshotID)
|
||||
do {
|
||||
_ = try await router.handle(
|
||||
cmd: "click",
|
||||
payload: .object([
|
||||
"pid": .int(Int(pid)),
|
||||
"index": .string(handle.rawValue),
|
||||
])
|
||||
)
|
||||
XCTFail("the old generation must not reach the action")
|
||||
} catch let error as CUError {
|
||||
XCTAssertEqual(error.code, "stale_snapshot")
|
||||
}
|
||||
let unchangedState = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let (_, unchangedLine) = try publishedHandle(label: "Bold", state: unchangedState)
|
||||
XCTAssertTrue(unchangedLine.contains("Value: 1"), unchangedLine)
|
||||
|
||||
FileManager.default.createFile(atPath: stop.path, contents: Data())
|
||||
try await waitUntil(description: "fixture exit") { process.isTerminated }
|
||||
}
|
||||
|
||||
private func verifyVisibleClicks(
|
||||
cursor: VirtualCursor, router: CommandRouter, process: NSRunningApplication, gestures: URL
|
||||
) async throws {
|
||||
let pid = process.processIdentifier
|
||||
let captured = try await router.handle(cmd: "get_app_state", payload: .object([
|
||||
"pid": .int(Int(pid)), "disableDiff": .bool(true),
|
||||
]))
|
||||
let shot = try XCTUnwrap(captured["screenshot"])
|
||||
let observed = try await AXTree.appState(pid: pid, disableDiff: true)
|
||||
let (handle, _) = try publishedHandle(label: "Drag fixture", state: observed)
|
||||
let frame = try XCTUnwrap(AXTree.record(pid: pid, index: handle.index)?.frameGlobal)
|
||||
let point = CGPoint(x: frame.x + frame.w / 2, y: frame.y + frame.h / 2)
|
||||
let x = (point.x - (try XCTUnwrap(shot["originX"]?.asDouble)))
|
||||
* Double(try XCTUnwrap(shot["width"]?.asInt)) / (try XCTUnwrap(shot["pointWidth"]?.asDouble))
|
||||
let y = (point.y - (try XCTUnwrap(shot["originY"]?.asDouble)))
|
||||
* Double(try XCTUnwrap(shot["height"]?.asInt)) / (try XCTUnwrap(shot["pointHeight"]?.asDouble))
|
||||
|
||||
// Observe real AppKit layers, not a mock callback or just a successful
|
||||
// input result. A non-headless cursor that was never shown used to let
|
||||
// every integration test skip the exact showClick branch that crashed.
|
||||
// The first indexed action already preloaded this cursor's windows.
|
||||
let overlays = NSApplication.shared.windows.filter { $0.ignoresMouseEvents && $0.level.rawValue == Int(CGShieldingWindowLevel()) }
|
||||
XCTAssertFalse(overlays.isEmpty)
|
||||
func ripples() -> [CALayer] {
|
||||
overlays.flatMap { $0.contentView?.layer?.sublayers ?? [] }.filter {
|
||||
$0.animation(forKey: "ripple") != nil || $0.animation(forKey: "rasterRipple") != nil
|
||||
}
|
||||
}
|
||||
func click(button: String = "left", count: Int = 1) async throws {
|
||||
let result = try await router.handle(cmd: "click", payload: .object([
|
||||
"pid": .int(Int(pid)), "x": .double(x), "y": .double(y),
|
||||
"mouse_button": .string(button), "click_count": .int(count),
|
||||
]))
|
||||
XCTAssertEqual(result, .bool(true))
|
||||
}
|
||||
cursor.show()
|
||||
XCTAssertTrue(process.activate(options: []))
|
||||
try await waitUntil(description: "disposable receiver is foreground") {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier == pid
|
||||
}
|
||||
for step in 0..<12 {
|
||||
// Retain the layers across await: a removed ripple's address can
|
||||
// otherwise be reused by the next layer and look like no new ring.
|
||||
let before = ripples()
|
||||
try await click(button: step % 3 == 2 ? "right" : "left", count: step % 3 == 1 ? 2 : 1)
|
||||
XCTAssertTrue(overlays.contains { $0.isVisible })
|
||||
XCTAssertTrue(ripples().contains { layer in !before.contains { $0 === layer } }, "Click \(step) must create visible feedback")
|
||||
}
|
||||
try await waitUntil(description: "16 mouse-up receipts from 12 single/double/right click commands") {
|
||||
guard let data = try? Data(contentsOf: gestures),
|
||||
let receipt = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
|
||||
return receipt["completed"] as? Int == 16
|
||||
}
|
||||
let receipt = try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(contentsOf: gestures)) as? [String: Any])
|
||||
let events = try XCTUnwrap(receipt["events"] as? [[String: Any]])
|
||||
XCTAssertEqual(events.filter { $0["type"] as? UInt == NSEvent.EventType.rightMouseUp.rawValue }.count, 4)
|
||||
XCTAssertEqual(receipt["unpaired"] as? Int, 0)
|
||||
|
||||
// Put an independent host window to the right of the background
|
||||
// receiver, then cover it, then uncover it. WindowServer supplies the
|
||||
// actual occlusion evidence, as in the user's two-app layout.
|
||||
let app = NSApplication.shared
|
||||
let previousPolicy = app.activationPolicy()
|
||||
app.setActivationPolicy(.regular)
|
||||
let host = NSWindow(contentRect: NSRect(x: 660, y: 200, width: 240, height: 200), styleMask: [.titled], backing: .buffered, defer: false)
|
||||
host.isReleasedWhenClosed = false
|
||||
host.collectionBehavior = [.canJoinAllApplications]
|
||||
host.title = "Disposable Computer Use host"
|
||||
defer {
|
||||
host.orderOut(nil)
|
||||
host.close()
|
||||
app.setActivationPolicy(previousPolicy)
|
||||
WindowExposure.resetForTests()
|
||||
}
|
||||
host.makeKeyAndOrderFront(nil)
|
||||
app.activate(ignoringOtherApps: true)
|
||||
try await waitUntil(description: "disposable host is foreground") {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier == getpid()
|
||||
}
|
||||
func expectFeedback(exposed: Bool) async throws {
|
||||
try await waitUntil(description: exposed ? "target exposed" : "target covered") {
|
||||
WindowExposure.resetForTests()
|
||||
return WindowExposure.targetWindowExposed(at: point, targetPid: pid) == exposed
|
||||
}
|
||||
cursor.hide()
|
||||
cursor.show()
|
||||
try await click()
|
||||
XCTAssertEqual(overlays.contains { $0.isVisible }, exposed)
|
||||
XCTAssertEqual(!ripples().isEmpty, exposed)
|
||||
XCTAssertEqual(NSWorkspace.shared.frontmostApplication?.processIdentifier, getpid())
|
||||
}
|
||||
try await expectFeedback(exposed: true)
|
||||
host.setFrame(NSRect(x: point.x - 40, y: CGDisplayBounds(CGMainDisplayID()).height - point.y - 40, width: 240, height: 200), display: true)
|
||||
try await expectFeedback(exposed: false)
|
||||
host.setFrame(NSRect(x: 660, y: 200, width: 240, height: 200), display: true)
|
||||
try await expectFeedback(exposed: true)
|
||||
try await waitUntil(description: "three background clicks received, including covered target") {
|
||||
guard let data = try? Data(contentsOf: gestures),
|
||||
let receipt = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
|
||||
return receipt["completed"] as? Int == 19
|
||||
}
|
||||
cursor.hide()
|
||||
cursor.showClick(at: point, kind: .single)
|
||||
XCTAssertTrue(ripples().isEmpty, "Turn end must clear feedback")
|
||||
XCTAssertFalse(overlays.contains { $0.isVisible })
|
||||
print("[native-visible-click-smoke] observed 15 commands, 19 received clicks; checked foreground/exposed/covered/re-exposed/hidden feedback")
|
||||
}
|
||||
|
||||
private func runFixtureProcess(mismatchedWindowTitle: Bool, regularActivation: Bool, wideWindow: Bool) async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let readyPath = try XCTUnwrap(environment[Self.fixtureReadyPath])
|
||||
if environment[Self.fixtureMethods] == "1" {
|
||||
try NativeMethodContractFixture.run(root: URL(fileURLWithPath: readyPath).deletingLastPathComponent())
|
||||
return
|
||||
}
|
||||
let stopPath = try XCTUnwrap(environment[Self.fixtureStopPath])
|
||||
let title = try XCTUnwrap(environment[Self.fixtureTitle])
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(regularActivation ? .regular : .accessory)
|
||||
app.finishLaunching()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 200, y: 200, width: wideWindow ? 1398 : 420, height: wideWindow ? 737 : 180),
|
||||
styleMask: [.titled],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
if regularActivation {
|
||||
// This is a utility receiver, not a document in the user's active
|
||||
// app set. A regular app's window can otherwise be reduced to a
|
||||
// WindowServer preview while AX still reports its full-size frame.
|
||||
// Keep it eligible to join the current set without changing any
|
||||
// system preference, window level, or production input safeguard.
|
||||
window.collectionBehavior = [.canJoinAllApplications]
|
||||
}
|
||||
window.title = title
|
||||
if mismatchedWindowTitle {
|
||||
// Chrome exposes a decorated AX title while WindowServer uses the
|
||||
// page title (and may add an audio indicator). Both refer to the
|
||||
// same window. Reproduce that mismatch without a real browser.
|
||||
window.setAccessibilityTitle("\(title) - Google Chrome - Fixture")
|
||||
}
|
||||
// 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 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(formattingGroup)
|
||||
content.addSubview(alignmentGroup)
|
||||
let canvas = DragReceiptView(frame: wideWindow
|
||||
? NSRect(x: 20, y: 130, width: 1358, height: 580)
|
||||
: NSRect(x: 20, y: 15, width: 380, height: 30))
|
||||
canvas.receiptPath = try XCTUnwrap(environment[Self.fixtureGesturePath])
|
||||
canvas.setAccessibilityElement(true)
|
||||
canvas.setAccessibilityRole(.group)
|
||||
canvas.setAccessibilityLabel("Drag fixture")
|
||||
content.addSubview(canvas)
|
||||
window.contentView = content
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
app.activate()
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.close()
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
FileManager.default.createFile(atPath: readyPath, contents: Data())
|
||||
// XCTest's async wait services AX requests, but does not run AppKit's
|
||||
// native event dispatcher. A receiving-app fixture needs NSApp.run to
|
||||
// consume synthetic activation and mouse events just like a real app.
|
||||
let stopTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { _ in
|
||||
if FileManager.default.fileExists(atPath: stopPath) { exit(0) }
|
||||
}
|
||||
defer { stopTimer.invalidate() }
|
||||
app.run()
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
description: String,
|
||||
timeout: Duration = .seconds(5),
|
||||
diagnostic: () -> String = { "" },
|
||||
_ predicate: () -> Bool
|
||||
) async throws {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while clock.now < deadline {
|
||||
if predicate() { return }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTFail("Timed out waiting for \(description): \(diagnostic())")
|
||||
throw FixtureError.timedOut
|
||||
}
|
||||
|
||||
private func makeFixtureApp(in root: URL) throws -> URL {
|
||||
let app = root.appendingPathComponent("AXPublicationFixture.app", isDirectory: true)
|
||||
let contents = app.appendingPathComponent("Contents", isDirectory: true)
|
||||
let macOS = contents.appendingPathComponent("MacOS", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true)
|
||||
|
||||
let executable = macOS.appendingPathComponent("AXPublicationFixture")
|
||||
try FileManager.default.copyItem(
|
||||
at: URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath(),
|
||||
to: executable
|
||||
)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755],
|
||||
ofItemAtPath: executable.path
|
||||
)
|
||||
let info: [String: Any] = [
|
||||
"CFBundleExecutable": "AXPublicationFixture",
|
||||
"CFBundleIdentifier": "dev.cchaha.tests.ax-publication-fixture",
|
||||
"CFBundleName": "AXPublicationFixture",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleVersion": "1",
|
||||
]
|
||||
let plist = try PropertyListSerialization.data(
|
||||
fromPropertyList: info,
|
||||
format: .xml,
|
||||
options: 0
|
||||
)
|
||||
try plist.write(to: contents.appendingPathComponent("Info.plist"), options: .atomic)
|
||||
return app
|
||||
}
|
||||
|
||||
private func publishedHandle(
|
||||
label: String,
|
||||
state: AXTree.Result
|
||||
) throws -> (SnapshotElementHandle, String) {
|
||||
let line = try XCTUnwrap(
|
||||
state.axText.split(separator: "\n").first {
|
||||
$0.contains("Description: \(label)") || $0.hasSuffix("container \(label)")
|
||||
}.map(String.init),
|
||||
state.axText
|
||||
)
|
||||
let rawHandle = try XCTUnwrap(
|
||||
line.trimmingCharacters(in: .whitespaces).split(separator: " ").first.map(String.init)
|
||||
)
|
||||
return (try XCTUnwrap(SnapshotElementHandle(rawValue: rawHandle)), line)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records what the receiving process actually consumed, independently of the
|
||||
/// sender's event builder. AppKit may coalesce motion, so assert complete drag
|
||||
/// gestures rather than requiring every intermediate event at this boundary.
|
||||
@MainActor
|
||||
private final class DragReceiptView: NSView {
|
||||
var receiptPath = ""
|
||||
private var held = false
|
||||
private var dragged = false
|
||||
private var completed = 0
|
||||
private var drags = 0
|
||||
private var unpaired = 0
|
||||
private var events: [[String: Any]] = []
|
||||
private var keys: [[String: Any]] = []
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
if held { unpaired += 1 }
|
||||
held = true
|
||||
dragged = false
|
||||
record(event)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
keys.append([
|
||||
"keyCode": Int(event.keyCode),
|
||||
"modifiers": event.modifierFlags.intersection([.command, .control, .shift, .option]).rawValue,
|
||||
])
|
||||
writeReceipt()
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
if !held { unpaired += 1 }
|
||||
dragged = true
|
||||
record(event)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if !held { unpaired += 1 }
|
||||
completed += 1
|
||||
if dragged { drags += 1 }
|
||||
held = false
|
||||
record(event)
|
||||
}
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) { mouseDown(with: event) }
|
||||
override func rightMouseUp(with event: NSEvent) { mouseUp(with: event) }
|
||||
|
||||
private func record(_ event: NSEvent) {
|
||||
events.append([
|
||||
"type": event.type.rawValue,
|
||||
"number": event.eventNumber,
|
||||
"clicks": event.clickCount,
|
||||
"x": event.locationInWindow.x,
|
||||
"y": event.locationInWindow.y,
|
||||
"timestamp": event.timestamp,
|
||||
])
|
||||
writeReceipt()
|
||||
}
|
||||
|
||||
private func writeReceipt() {
|
||||
let receipt: [String: Any] = [
|
||||
"completed": completed, "drags": drags, "unpaired": unpaired,
|
||||
"held": held, "events": events, "keys": keys,
|
||||
]
|
||||
if let data = try? JSONSerialization.data(withJSONObject: receipt) {
|
||||
try? data.write(to: URL(fileURLWithPath: receiptPath), options: .atomic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FixtureIdentityDiagnostics {
|
||||
let pid: pid_t
|
||||
var stage = "state"
|
||||
private(set) var samples = 0
|
||||
private var observed = Set<String>()
|
||||
#if DEBUG
|
||||
private let previous: ((ProvenProcessTarget, AXTreeProcessIdentity?) -> Void)?
|
||||
#endif
|
||||
|
||||
init(pid: pid_t) {
|
||||
self.pid = pid
|
||||
#if DEBUG
|
||||
previous = Injection.targetValidationObserver
|
||||
Injection.targetValidationObserver = { [weak self] expected, current in
|
||||
guard let self, expected.pid == self.pid else { return }
|
||||
self.samples += 1
|
||||
self.observed.insert(Self.describe(current))
|
||||
if expected.validatedPid(currentIdentity: current) == nil {
|
||||
print("[identity-validation] failed pid=\(pid) stage=\(self.stage) sample=\(self.samples) expected=\(Self.describe(expected.identity)) actual=\(Self.describe(current))")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func finish() {
|
||||
#if DEBUG
|
||||
Injection.targetValidationObserver = previous
|
||||
print("[identity-validation] pid=\(pid) samples=\(samples) identities=\(observed.sorted())")
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func describe(_ identity: AXTreeProcessIdentity?) -> String {
|
||||
guard let identity else { return "nil" }
|
||||
return "bundle=\(identity.bundleID ?? "nil") path=\(identity.executablePath ?? "nil") launch=\(identity.launchTime.map(String.init(describing:)) ?? "nil") bits=\(identity.launchTime.map { String($0.bitPattern, radix: 16) } ?? "nil")"
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user