mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
428 lines
14 KiB
Swift
428 lines
14 KiB
Swift
import XCTest
|
|
@testable import cc_haha_computer_use
|
|
|
|
@MainActor
|
|
final class CommandRouterSafetyTests: XCTestCase {
|
|
private let processA = AXTreeProcessIdentity(
|
|
bundleID: "com.example.a",
|
|
executablePath: "/Applications/A.app/Contents/MacOS/A",
|
|
launchTime: 100
|
|
)
|
|
|
|
private let processB = AXTreeProcessIdentity(
|
|
bundleID: "com.example.a",
|
|
executablePath: "/Applications/A.app/Contents/MacOS/A",
|
|
launchTime: 200
|
|
)
|
|
|
|
func testHeadlessMouseDownAndMouseUpRequireDaemon() async throws {
|
|
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
|
_ = monitor.startAndWait()
|
|
defer { monitor.stop() }
|
|
let router = CommandRouter(
|
|
cursor: VirtualCursor(headless: true),
|
|
capabilities: Capabilities(headless: true),
|
|
inputMonitor: monitor
|
|
)
|
|
|
|
for command in ["mouse_down", "mouse_up"] {
|
|
do {
|
|
_ = try await router.handle(
|
|
cmd: command,
|
|
payload: .object([:])
|
|
)
|
|
XCTFail("expected daemon_required for \(command)")
|
|
} catch let error as CUError {
|
|
XCTAssertEqual(error.code, "daemon_required")
|
|
}
|
|
}
|
|
}
|
|
|
|
func testInputMonitorStateSerializesCountersAsDecimalStrings() async throws {
|
|
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
|
let router = CommandRouter(
|
|
cursor: VirtualCursor(headless: true),
|
|
capabilities: Capabilities(headless: true),
|
|
inputMonitor: monitor
|
|
)
|
|
|
|
let result = try await router.handle(
|
|
cmd: "input_monitor_state",
|
|
payload: .object([:])
|
|
)
|
|
|
|
for key in ["epoch", "continuityGeneration"] {
|
|
guard let value = result[key]?.asString else {
|
|
return XCTFail("expected decimal string for \(key)")
|
|
}
|
|
XCTAssertNotNil(UInt64(value), "expected UInt64 decimal for \(key)")
|
|
}
|
|
}
|
|
|
|
func testHeldInputStateIsReadOnlyAndStartsEmpty() async throws {
|
|
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
|
let router = CommandRouter(
|
|
cursor: VirtualCursor(headless: true),
|
|
capabilities: Capabilities(headless: true),
|
|
inputMonitor: monitor
|
|
)
|
|
|
|
let first = try await router.handle(
|
|
cmd: "held_input_state",
|
|
payload: .object([:])
|
|
)
|
|
let second = try await router.handle(
|
|
cmd: "held_input_state",
|
|
payload: .object([:])
|
|
)
|
|
|
|
XCTAssertEqual(first, .object([
|
|
"keys": .array([]),
|
|
"buttons": .array([]),
|
|
]))
|
|
XCTAssertEqual(second, first)
|
|
}
|
|
|
|
func testSessionResetClearsScreenshotCoordinateTransforms() throws {
|
|
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
|
let router = CommandRouter(
|
|
cursor: VirtualCursor(headless: true),
|
|
capabilities: Capabilities(headless: true),
|
|
inputMonitor: monitor
|
|
)
|
|
CommandRouter.recordShotTransform(
|
|
pid: 77,
|
|
originX: 100,
|
|
originY: 200,
|
|
pointWidth: 500,
|
|
pointHeight: 400,
|
|
imageWidth: 1_000,
|
|
imageHeight: 800,
|
|
processIdentity: processA,
|
|
windowID: 17
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
try CommandRouter.toGlobalPoint(
|
|
x: 20,
|
|
y: 40,
|
|
pid: 77,
|
|
currentProcessIdentity: processA,
|
|
currentWindowID: 17
|
|
),
|
|
CGPoint(x: 110, y: 220)
|
|
)
|
|
|
|
router.resetSessionState()
|
|
|
|
XCTAssertThrowsError(try CommandRouter.toGlobalPoint(
|
|
x: 20,
|
|
y: 40,
|
|
pid: 77,
|
|
currentProcessIdentity: processA,
|
|
currentWindowID: 17
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "stale_snapshot")
|
|
}
|
|
}
|
|
|
|
func testSessionResetAlsoInvalidatesTheWindowCaptureProvider() {
|
|
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
|
let provider = WindowCaptureProviderSpy()
|
|
let router = CommandRouter(
|
|
cursor: VirtualCursor(headless: true),
|
|
capabilities: Capabilities(headless: true),
|
|
inputMonitor: monitor,
|
|
windowCaptureProvider: provider
|
|
)
|
|
|
|
router.resetSessionState()
|
|
|
|
XCTAssertEqual(provider.invalidateCount, 1)
|
|
}
|
|
|
|
func testDiscardedWindowSnapshotForcesTheRetryToReturnAFullTree() {
|
|
XCTAssertFalse(CommandRouter.effectiveDisableDiff(
|
|
requested: false,
|
|
forceFullSnapshot: false
|
|
))
|
|
XCTAssertTrue(CommandRouter.effectiveDisableDiff(
|
|
requested: false,
|
|
forceFullSnapshot: true
|
|
))
|
|
XCTAssertTrue(CommandRouter.effectiveDisableDiff(
|
|
requested: true,
|
|
forceFullSnapshot: false
|
|
))
|
|
}
|
|
|
|
func testCoordinateTransformNeverFallsBackAcrossApps() {
|
|
CommandRouter.clearShotTransformsForTesting()
|
|
CommandRouter.recordShotTransform(
|
|
pid: 77,
|
|
originX: 100,
|
|
originY: 200,
|
|
pointWidth: 500,
|
|
pointHeight: 400,
|
|
imageWidth: 1_000,
|
|
imageHeight: 800,
|
|
processIdentity: processA,
|
|
windowID: 17
|
|
)
|
|
|
|
XCTAssertThrowsError(try CommandRouter.toGlobalPoint(
|
|
x: 20,
|
|
y: 40,
|
|
pid: 88,
|
|
currentProcessIdentity: processA,
|
|
currentWindowID: 17
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "stale_snapshot")
|
|
}
|
|
}
|
|
|
|
func testCoordinateTransformRejectsProcessAndWindowChanges() {
|
|
CommandRouter.clearShotTransformsForTesting()
|
|
CommandRouter.recordShotTransform(
|
|
pid: 77,
|
|
originX: 100,
|
|
originY: 200,
|
|
pointWidth: 500,
|
|
pointHeight: 400,
|
|
imageWidth: 1_000,
|
|
imageHeight: 800,
|
|
processIdentity: processA,
|
|
windowID: 17
|
|
)
|
|
|
|
XCTAssertThrowsError(try CommandRouter.toGlobalPoint(
|
|
x: 20,
|
|
y: 40,
|
|
pid: 77,
|
|
currentProcessIdentity: processB,
|
|
currentWindowID: 17
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "stale_process")
|
|
}
|
|
CommandRouter.recordShotTransform(
|
|
pid: 77,
|
|
originX: 100,
|
|
originY: 200,
|
|
pointWidth: 500,
|
|
pointHeight: 400,
|
|
imageWidth: 1_000,
|
|
imageHeight: 800,
|
|
processIdentity: processA,
|
|
windowID: 17
|
|
)
|
|
XCTAssertThrowsError(try CommandRouter.toGlobalPoint(
|
|
x: 20,
|
|
y: 40,
|
|
pid: 77,
|
|
currentProcessIdentity: processA,
|
|
currentWindowID: 18
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "target_window_changed")
|
|
}
|
|
}
|
|
|
|
func testCoordinateTransformRejectsPointsOutsideLatestScreenshot() {
|
|
CommandRouter.clearShotTransformsForTesting()
|
|
CommandRouter.recordShotTransform(
|
|
pid: 77,
|
|
originX: 100,
|
|
originY: 200,
|
|
pointWidth: 500,
|
|
pointHeight: 400,
|
|
imageWidth: 1_000,
|
|
imageHeight: 800,
|
|
processIdentity: processA,
|
|
windowID: 17
|
|
)
|
|
|
|
for point in [
|
|
CGPoint(x: -1, y: 0),
|
|
CGPoint(x: 0, y: -1),
|
|
CGPoint(x: 1_000, y: 0),
|
|
CGPoint(x: 0, y: 800),
|
|
] {
|
|
XCTAssertThrowsError(try CommandRouter.toGlobalPoint(
|
|
x: point.x,
|
|
y: point.y,
|
|
pid: 77,
|
|
currentProcessIdentity: processA,
|
|
currentWindowID: 17
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "bad_payload")
|
|
}
|
|
}
|
|
|
|
XCTAssertNoThrow(try CommandRouter.toGlobalPoint(
|
|
x: 999.999,
|
|
y: 799.999,
|
|
pid: 77,
|
|
currentProcessIdentity: processA,
|
|
currentWindowID: 17
|
|
))
|
|
}
|
|
|
|
func testEverySemanticCommandExceptListAppsRequiresExplicitTarget() async throws {
|
|
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
|
let router = CommandRouter(
|
|
cursor: VirtualCursor(headless: true),
|
|
capabilities: Capabilities(headless: true),
|
|
inputMonitor: monitor
|
|
)
|
|
let cases: [(String, JSONValue)] = [
|
|
("resolve_app_target", .object([:])),
|
|
("get_app_state", .object([:])),
|
|
("click", .object(["x": .int(1), "y": .int(1)])),
|
|
("set_value", .object(["index": .string("g1:0"), "value": .string("x")])),
|
|
("select_text", .object(["index": .string("g1:0"), "text": .string("x")])),
|
|
("perform_secondary_action", .object(["index": .string("g1:0"), "action": .string("Raise")])),
|
|
("scroll", .object(["x": .int(1), "y": .int(1), "direction": .string("down")])),
|
|
("type_text", .object(["text": .string("x")])),
|
|
("press_key", .object(["key": .string("Return")])),
|
|
("drag", .object([
|
|
"from": .object(["x": .int(1), "y": .int(1)]),
|
|
"to": .object(["x": .int(2), "y": .int(2)]),
|
|
])),
|
|
]
|
|
|
|
for (command, payload) in cases {
|
|
do {
|
|
_ = try await router.handle(cmd: command, payload: payload)
|
|
XCTFail("expected explicit-target failure for \(command)")
|
|
} catch let error as CUError {
|
|
XCTAssertEqual(error.code, "no_target", command)
|
|
}
|
|
}
|
|
|
|
_ = try await router.handle(cmd: "list_apps", payload: .object([:]))
|
|
}
|
|
|
|
func testRuntimeArgumentBoundsFailClosed() throws {
|
|
XCTAssertEqual(try CommandRouter.parseClickCount(.object([:])), 1)
|
|
XCTAssertEqual(try CommandRouter.parseClickCount(.object(["click_count": .int(3)])), 3)
|
|
for invalid in [JSONValue.int(0), .int(4), .double(1.5), .bool(true), .string("2")] {
|
|
XCTAssertThrowsError(try CommandRouter.parseClickCount(.object([
|
|
"click_count": invalid,
|
|
]))) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "bad_payload")
|
|
}
|
|
}
|
|
|
|
XCTAssertEqual(try CommandRouter.parsePages(.object([:])), 1)
|
|
XCTAssertEqual(try CommandRouter.parsePages(.object(["pages": .double(0.5)])), 0.5)
|
|
for invalid in [
|
|
JSONValue.int(0), .int(11), .double(.infinity), .bool(true), .string("2"),
|
|
] {
|
|
XCTAssertThrowsError(try CommandRouter.parsePages(.object([
|
|
"pages": invalid,
|
|
]))) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "bad_payload")
|
|
}
|
|
}
|
|
|
|
for valid in ["left", "middle", "right", "back", "forward"] {
|
|
XCTAssertEqual(
|
|
try CommandRouter.parseMouseButton(.object([
|
|
"mouse_button": .string(valid),
|
|
])).rawValue,
|
|
valid
|
|
)
|
|
}
|
|
for invalid in ["banana", "", "4"] {
|
|
XCTAssertThrowsError(try CommandRouter.parseMouseButton(.object([
|
|
"mouse_button": .string(invalid),
|
|
]))) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "bad_payload")
|
|
}
|
|
}
|
|
}
|
|
|
|
func testFocusTargetedActionsRequireMatchingSnapshotProcessLifetime() throws {
|
|
XCTAssertThrowsError(try SnapshotProcessGuard.validate(
|
|
pid: 77,
|
|
snapshot: nil,
|
|
current: processA,
|
|
expected: nil
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "stale_snapshot")
|
|
}
|
|
|
|
let snapshot = AXTreeSnapshotEvidence(
|
|
processIdentity: processA,
|
|
keyWindowID: 17
|
|
)
|
|
XCTAssertNoThrow(try SnapshotProcessGuard.validate(
|
|
pid: 77,
|
|
snapshot: snapshot,
|
|
current: processA,
|
|
expected: processA
|
|
))
|
|
for (current, expected) in [
|
|
(processB, Optional<AXTreeProcessIdentity>.none),
|
|
(processA, Optional(processB)),
|
|
] {
|
|
XCTAssertThrowsError(try SnapshotProcessGuard.validate(
|
|
pid: 77,
|
|
snapshot: snapshot,
|
|
current: current,
|
|
expected: expected
|
|
)) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "stale_process")
|
|
}
|
|
}
|
|
}
|
|
|
|
func testExpectedProcessIdentityWireIsStrictAndIncludesPID() throws {
|
|
let expected = try CommandRouter.expectedProcessTarget(.object([
|
|
"expectedProcessIdentity": .object([
|
|
"pid": .int(77),
|
|
"bundleId": .string("com.example.a"),
|
|
"executablePath": .string("/Applications/A.app/Contents/MacOS/A"),
|
|
"launchTime": .double(100),
|
|
]),
|
|
]))
|
|
XCTAssertEqual(expected?.pid, 77)
|
|
XCTAssertEqual(expected?.identity, processA)
|
|
|
|
for invalidLaunchTime in [
|
|
JSONValue.bool(true),
|
|
.string("100"),
|
|
.double(.infinity),
|
|
] {
|
|
XCTAssertThrowsError(try CommandRouter.expectedProcessTarget(.object([
|
|
"expectedProcessIdentity": .object([
|
|
"pid": .int(77),
|
|
"bundleId": .string("com.example.a"),
|
|
"executablePath": .string("/Applications/A.app/Contents/MacOS/A"),
|
|
"launchTime": invalidLaunchTime,
|
|
]),
|
|
]))) {
|
|
XCTAssertEqual(($0 as? CUError)?.code, "bad_payload")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class WindowCaptureProviderSpy: WindowCaptureProviding {
|
|
private(set) var invalidateCount = 0
|
|
|
|
func windowShot(
|
|
pid: pid_t,
|
|
processIdentity: AXTreeProcessIdentity,
|
|
preferredWindowID: CGWindowID?,
|
|
scale: Double,
|
|
newerThanUptime: TimeInterval?
|
|
) async -> WindowShot? {
|
|
nil
|
|
}
|
|
|
|
func invalidate() {
|
|
invalidateCount += 1
|
|
}
|
|
}
|