Files
claude-code-haha/native/cu-helper/Tests/CuHelperTests/CommandRouterSafetyTests.swift
T
2026-08-31 11:25:38 +08:00

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
}
}