mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
56c6a9aa8e
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.
487 lines
17 KiB
Swift
487 lines
17 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 testRoundedScreenshotDimensionsPreserveOfficialUniformCoordinateScale() throws {
|
||
defer { CommandRouter.clearShotTransformsForTesting() }
|
||
CommandRouter.recordShotTransform(
|
||
pid: 77, originX: 100, originY: 200,
|
||
pointWidth: 1398, pointHeight: 769,
|
||
imageWidth: 1397, imageHeight: 768,
|
||
processIdentity: processA, windowID: 17,
|
||
pixelsPerPoint: 768.0 / 769.0
|
||
)
|
||
let point = try CommandRouter.toGlobalPoint(
|
||
x: 300, y: 180, pid: 77,
|
||
currentProcessIdentity: processA, currentWindowID: 17
|
||
)
|
||
// Actual official native receiver, 1398×769 points → 1397×768 JPEG.
|
||
// Both axes use the original fit, before the pixel buffer's ceil.
|
||
XCTAssertEqual(point.x, 400.390625, accuracy: 0.000001)
|
||
XCTAssertEqual(point.y, 380.234375, accuracy: 0.000001)
|
||
}
|
||
|
||
func testInvalidUniformCaptureScaleCannotPublishACoordinateTransform() throws {
|
||
defer { CommandRouter.clearShotTransformsForTesting() }
|
||
for scale in [0, -1, Double.nan, Double.infinity] {
|
||
CommandRouter.recordShotTransform(
|
||
pid: 77, originX: 100, originY: 200,
|
||
pointWidth: 1398, pointHeight: 769,
|
||
imageWidth: 1397, imageHeight: 768,
|
||
processIdentity: processA, windowID: 17,
|
||
pixelsPerPoint: scale
|
||
)
|
||
XCTAssertThrowsError(try CommandRouter.toGlobalPoint(
|
||
x: 300, y: 180, pid: 77,
|
||
currentProcessIdentity: processA, currentWindowID: 17
|
||
)) { XCTAssertEqual(($0 as? CUError)?.code, "stale_snapshot") }
|
||
}
|
||
}
|
||
|
||
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 testTurnStateResetPreservesTheDaemonLifetimeWindowCaptureProvider() {
|
||
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, 0)
|
||
router.invalidateWindowCaptureStream()
|
||
XCTAssertEqual(provider.invalidateCount, 1)
|
||
}
|
||
|
||
func testSessionResetClearsThePreviousMutationSettleDeadline() async throws {
|
||
let monitor = PhysicalInputEpochMonitor(counterReader: { _ in 0 })
|
||
let router = CommandRouter(
|
||
cursor: VirtualCursor(headless: true),
|
||
capabilities: Capabilities(headless: true),
|
||
inputMonitor: monitor
|
||
)
|
||
let process = try XCTUnwrap(ProvenProcessTarget(pid: 77, identity: processA))
|
||
let lease = try ForegroundLease.acquire(target: process, runtime: ForegroundLeaseRuntime(
|
||
inputSnapshot: { PhysicalInputEpochSnapshot(epoch: 0, available: true) },
|
||
frontmostTarget: { nil },
|
||
currentIdentity: { _ in self.processA },
|
||
activate: { _ in XCTFail("must not activate"); return false },
|
||
verifyFrontmost: { _ in XCTFail("must not activate"); return false }
|
||
))
|
||
_ = try await ForegroundMutationRunner.run(lease: lease) { true }
|
||
XCTAssertNotNil(MutationClock.lastMutation())
|
||
router.resetSessionState()
|
||
XCTAssertNil(MutationClock.lastMutation())
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|