Files
openclaw/apps/macos/Tests/OpenClawIPCTests/ComputerActionServiceTests.swift
2026-07-10 08:37:37 +01:00

716 lines
25 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@MainActor
struct ComputerActionServiceTests {
private enum SyntheticPostError: Error {
case failed
}
private actor AsyncSignal {
private var signaled = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
guard !self.signaled else { return }
await withCheckedContinuation { continuation in
self.waiters.append(continuation)
}
}
func signal() {
self.signaled = true
let waiters = self.waiters
self.waiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
}
@MainActor
private final class ActionProbe {
let firstStarted = AsyncSignal()
let releaseFirst = AsyncSignal()
private(set) var enteredActionIDs: [Int] = []
private(set) var activeActionCount = 0
private(set) var maximumActiveActionCount = 0
func perform(
_ params: OpenClawComputerActParams,
lifecycleGeneration: UInt64) async throws -> OpenClawComputerActResult
{
_ = lifecycleGeneration
let actionID = Int(params.x ?? -1)
self.enteredActionIDs.append(actionID)
self.activeActionCount += 1
self.maximumActiveActionCount = max(
self.maximumActiveActionCount,
self.activeActionCount)
defer { self.activeActionCount -= 1 }
if actionID == 1 {
await self.firstStarted.signal()
await self.releaseFirst.wait()
}
try Task.checkCancellation()
return OpenClawComputerActResult(ok: true, cursorX: Double(actionID), cursorY: 0)
}
}
@MainActor
private final class LifecycleReleaseProbe {
var allowed: Bool
private(set) var attempts = 0
init(allowed: Bool) {
self.allowed = allowed
}
func attempt() -> Bool {
self.attempts += 1
return self.allowed
}
}
private final class CancellationHopProbe: @unchecked Sendable {
private let lock = NSLock()
private var pending: [@MainActor @Sendable () -> Void] = []
var pendingCount: Int {
self.lock.withLock { self.pending.count }
}
func schedule(_ operation: @escaping @MainActor @Sendable () -> Void) {
self.lock.withLock { self.pending.append(operation) }
}
@MainActor
func runAll() {
let operations = self.lock.withLock {
let pending = self.pending
self.pending.removeAll()
return pending
}
for operation in operations {
operation()
}
}
}
@MainActor
private final class ActionTaskBox {
var task: Task<OpenClawComputerActResult, Error>?
}
private func isLifecycleChanged(_ error: Error?) -> Bool {
guard let error = error as? ComputerActionService.ComputerActionError else { return false }
if case .lifecycleChanged = error {
return true
}
return false
}
private func validationError(
_ operation: () throws -> Void) -> ComputerActionService.ComputerActionError?
{
do {
try operation()
return nil
} catch let error as ComputerActionService.ComputerActionError {
return error
} catch {
return nil
}
}
@Test func `coordinate input requires the current display frame identity`() throws {
let currentFrameId = "display-frame:v1:current"
let missing = OpenClawComputerActParams(
action: .leftClick,
x: 1,
y: 2,
refWidth: 1280)
let missingError = self.validationError {
try ComputerActionService.validateDisplayFrame(missing, currentFrameId: currentFrameId)
}
if case .some(.missingDisplayFrameId) = missingError {} else {
Issue.record("expected missingDisplayFrameId")
}
let stale = OpenClawComputerActParams(
action: .leftClick,
displayFrameId: "display-frame:v1:stale",
x: 1,
y: 2,
refWidth: 1280)
let staleError = self.validationError {
try ComputerActionService.validateDisplayFrame(stale, currentFrameId: currentFrameId)
}
if case .some(.displayFrameChanged) = staleError {} else {
Issue.record("expected displayFrameChanged")
}
let current = OpenClawComputerActParams(
action: .leftClick,
displayFrameId: currentFrameId,
x: 1,
y: 2,
refWidth: 1280)
try ComputerActionService.validateDisplayFrame(current, currentFrameId: currentFrameId)
let missingScale = OpenClawComputerActParams(
action: .leftClick,
displayFrameId: currentFrameId,
x: 1,
y: 2)
let missingScaleError = self.validationError {
try ComputerActionService.validateDisplayFrame(
missingScale,
currentFrameId: currentFrameId)
}
if case .some(.invalidReferenceWidth) = missingScaleError {} else {
Issue.record("expected invalidReferenceWidth")
}
let display = OpenClawComputerDisplayGeometry(
originX: 0,
originY: 0,
widthPoints: 1280,
heightPoints: 800)
let screenshotFrameId = OpenClawComputerInputGeometry.displayFrameId(
displayID: 1,
sourceWidth: 2560,
sourceHeight: 1600,
referenceWidth: 1280,
display: display)
let mismatchedScaleFrameId = OpenClawComputerInputGeometry.displayFrameId(
displayID: 1,
sourceWidth: 2560,
sourceHeight: 1600,
referenceWidth: 640,
display: display)
let mismatchedScale = OpenClawComputerActParams(
action: .leftClick,
displayFrameId: screenshotFrameId,
x: 1,
y: 2,
refWidth: 640)
let mismatchedScaleError = self.validationError {
try ComputerActionService.validateDisplayFrame(
mismatchedScale,
currentFrameId: mismatchedScaleFrameId)
}
if case .some(.displayFrameChanged) = mismatchedScaleError {} else {
Issue.record("expected displayFrameChanged for mismatched refWidth")
}
// Cursor-relative/keyboard actions do not consume screenshot coordinates.
let keyboard = OpenClawComputerActParams(action: .type, text: "hello")
try ComputerActionService.validateDisplayFrame(keyboard, currentFrameId: currentFrameId)
}
@Test func `cursor-relative input stays inside the selected display`() throws {
let display = OpenClawComputerDisplayGeometry(
originX: 100,
originY: -50,
widthPoints: 800,
heightPoints: 600)
#expect(try ComputerActionService.validatedCurrentCursorPoint(
CGPoint(x: 100, y: -50),
display: display) == CGPoint(x: 100, y: -50))
#expect(throws: ComputerActionService.ComputerActionError.self) {
try ComputerActionService.validatedCurrentCursorPoint(
CGPoint(x: 99, y: 0),
display: display)
}
#expect(throws: ComputerActionService.ComputerActionError.self) {
try ComputerActionService.validatedCurrentCursorPoint(nil, display: display)
}
}
@Test func `integrated drag and duplicate down are rejected during a split hold`() throws {
#expect(throws: ComputerActionService.ComputerActionError.self) {
try ComputerActionService.validateHeldButtonTransition(
action: .leftClickDrag,
leftButtonDown: true)
}
#expect(throws: ComputerActionService.ComputerActionError.self) {
try ComputerActionService.validateHeldButtonTransition(
action: .leftMouseDown,
leftButtonDown: true)
}
#expect(throws: ComputerActionService.ComputerActionError.self) {
try ComputerActionService.validateHeldButtonTransition(
action: .doubleClick,
leftButtonDown: true)
}
try ComputerActionService.validateHeldButtonTransition(
action: .leftMouseUp,
leftButtonDown: true)
}
@Test func `left mouse up requires a service owned split hold`() throws {
#expect(throws: ComputerActionService.ComputerActionError.self) {
try ComputerActionService.validateHeldButtonTransition(
action: .leftMouseUp,
leftButtonDown: false)
}
try ComputerActionService.validateHeldButtonTransition(
action: .leftMouseDown,
leftButtonDown: false)
try ComputerActionService.validateHeldButtonTransition(
action: .leftMouseUp,
leftButtonDown: true)
}
@Test func `lifecycle release retries event creation failure before returning`() async {
let flags: CGEventFlags = [.maskCommand, .maskShift]
var attempts = 0
var postedFlags: [CGEventFlags] = []
let service = ComputerActionService { down, _, eventFlags in
#expect(!down)
attempts += 1
postedFlags.append(eventFlags)
if attempts == 1 {
throw ComputerActionService.ComputerActionError.eventCreationFailed
}
}
service.holdLeftButtonForTesting(flags: flags)
await service.releaseHeldInput(lifecycleGeneration: 1)
#expect(!service.isLeftButtonDownForTesting)
#expect(service.heldButtonFlagsForTesting.isEmpty)
#expect(!service.buttonWatchdogArmedForTesting)
#expect(attempts == 2)
#expect(postedFlags == [flags, flags])
}
@Test func `watchdog release retains ownership and rearms after post failure`() {
let flags: CGEventFlags = [.maskAlternate]
var attempts = 0
let service = ComputerActionService { down, _, eventFlags in
#expect(!down)
#expect(eventFlags == flags)
attempts += 1
if attempts == 1 {
throw SyntheticPostError.failed
}
}
service.holdLeftButtonForTesting(flags: flags)
service.fireButtonWatchdogForTesting()
#expect(service.isLeftButtonDownForTesting)
#expect(service.heldButtonFlagsForTesting == flags)
#expect(service.buttonWatchdogArmedForTesting)
service.fireButtonWatchdogForTesting()
#expect(!service.isLeftButtonDownForTesting)
#expect(service.heldButtonFlagsForTesting.isEmpty)
#expect(!service.buttonWatchdogArmedForTesting)
#expect(attempts == 2)
}
@Test func `failed explicit release retains added modifiers for watchdog retry`() {
let heldFlags: CGEventFlags = [.maskAlternate]
let releaseFlags: CGEventFlags = [.maskShift]
let expectedFlags = heldFlags.union(releaseFlags)
var attempts = 0
var postedFlags: [CGEventFlags] = []
let service = ComputerActionService { down, _, eventFlags in
#expect(!down)
attempts += 1
postedFlags.append(eventFlags)
if attempts == 1 {
throw SyntheticPostError.failed
}
}
service.holdLeftButtonForTesting(flags: heldFlags)
#expect(throws: SyntheticPostError.self) {
try service.releaseHeldButtonForTesting(additionalFlags: releaseFlags)
}
#expect(service.isLeftButtonDownForTesting)
#expect(service.heldButtonFlagsForTesting == expectedFlags)
#expect(service.buttonWatchdogArmedForTesting)
service.fireButtonWatchdogForTesting()
#expect(!service.isLeftButtonDownForTesting)
#expect(service.heldButtonFlagsForTesting.isEmpty)
#expect(!service.buttonWatchdogArmedForTesting)
#expect(attempts == 2)
#expect(postedFlags == [expectedFlags, expectedFlags])
}
@Test func `computer actions execute in FIFO order without overlap`() async throws {
let probe = ActionProbe()
let queue = ComputerActionExecutionQueue(onLifecycleRelease: { true })
let firstParams = OpenClawComputerActParams(action: .leftClick, x: 1, y: 0, refWidth: 1280)
let secondParams = OpenClawComputerActParams(action: .leftClick, x: 2, y: 0, refWidth: 1280)
let first = Task { @MainActor in
try await queue.perform(firstParams, lifecycleGeneration: 0, operation: probe.perform)
}
await probe.firstStarted.wait()
let second = Task { @MainActor in
try await queue.perform(secondParams, lifecycleGeneration: 0, operation: probe.perform)
}
while queue.pendingActionCountForTesting != 1 {
await Task.yield()
}
#expect(probe.enteredActionIDs == [1])
#expect(probe.maximumActiveActionCount == 1)
await probe.releaseFirst.signal()
_ = try await first.value
_ = try await second.value
#expect(probe.enteredActionIDs == [1, 2])
#expect(probe.maximumActiveActionCount == 1)
}
@Test func `cancelled queued action never executes`() async throws {
let probe = ActionProbe()
let queue = ComputerActionExecutionQueue(onLifecycleRelease: { true })
let firstParams = OpenClawComputerActParams(action: .leftClick, x: 1, y: 0, refWidth: 1280)
let cancelledParams = OpenClawComputerActParams(action: .leftClick, x: 2, y: 0, refWidth: 1280)
let first = Task { @MainActor in
try await queue.perform(firstParams, lifecycleGeneration: 0, operation: probe.perform)
}
await probe.firstStarted.wait()
let cancelled = Task { @MainActor in
try await queue.perform(cancelledParams, lifecycleGeneration: 0, operation: probe.perform)
}
while queue.pendingActionCountForTesting != 1 {
await Task.yield()
}
cancelled.cancel()
let cancellationError: Error?
do {
_ = try await cancelled.value
cancellationError = nil
} catch {
cancellationError = error
}
#expect(cancellationError is CancellationError)
#expect(probe.enteredActionIDs == [1])
await probe.releaseFirst.signal()
_ = try await first.value
#expect(probe.enteredActionIDs == [1])
}
@Test func `cancelled active action releases held input before it settles`() async {
let probe = ActionProbe()
let releaseProbe = LifecycleReleaseProbe(allowed: true)
let queue = ComputerActionExecutionQueue(onLifecycleRelease: releaseProbe.attempt)
let params = OpenClawComputerActParams(action: .leftMouseDown, x: 1, y: 0, refWidth: 1280)
let action = Task { @MainActor in
try await queue.perform(params, lifecycleGeneration: 0, operation: probe.perform)
}
await probe.firstStarted.wait()
action.cancel()
while releaseProbe.attempts == 0 {
await Task.yield()
}
#expect(releaseProbe.attempts == 1)
await probe.releaseFirst.signal()
_ = try? await action.value
#expect(releaseProbe.attempts == 2)
}
@Test func `cancellation retries failed release before its main actor hop`() async {
let cancellationHop = CancellationHopProbe()
var releaseAttempts = 0
let queue = ComputerActionExecutionQueue(
onLifecycleRelease: {
releaseAttempts += 1
return releaseAttempts >= 2
},
scheduleCancellationHop: cancellationHop.schedule)
let taskBox = ActionTaskBox()
let params = OpenClawComputerActParams(
action: .leftMouseDown,
x: 1,
y: 0,
refWidth: 1280)
let action = Task { @MainActor in
try await queue.perform(params, lifecycleGeneration: 0) { _, _ in
taskBox.task?.cancel()
return OpenClawComputerActResult(ok: true, cursorX: 1, cursorY: 0)
}
}
taskBox.task = action
let cancellationError: Error?
do {
_ = try await action.value
cancellationError = nil
} catch {
cancellationError = error
}
#expect(cancellationError is CancellationError)
#expect(cancellationHop.pendingCount == 1)
#expect(releaseAttempts == 2)
cancellationHop.runAll()
#expect(cancellationHop.pendingCount == 0)
#expect(releaseAttempts == 2)
}
@Test func `typing posts exactly one event pair per Swift grapheme`() async throws {
var posted: [Character] = []
let service = ComputerActionService(textGraphemePoster: { posted.append($0) })
let text = "A👨👩👧👦e\u{301}\n\t"
_ = try await service.typeTextForTesting(text)
#expect(posted == Array(text))
}
@Test func `caller cancellation stops typing before the next grapheme`() async {
let firstPosted = AsyncSignal()
let resumePoster = AsyncSignal()
var posted: [Character] = []
let service = ComputerActionService(textGraphemePoster: { grapheme in
posted.append(grapheme)
guard posted.count == 1 else { return }
await firstPosted.signal()
await resumePoster.wait()
})
let action = Task { @MainActor in
try await service.typeTextForTesting("A👨👩👧👦B")
}
await firstPosted.wait()
action.cancel()
await resumePoster.signal()
let cancellationError: Error?
do {
_ = try await action.value
cancellationError = nil
} catch {
cancellationError = error
}
#expect(cancellationError is CancellationError)
#expect(posted == ["A"])
}
@Test func `lifecycle replacement stops typing before the next grapheme`() async {
let firstPosted = AsyncSignal()
let resumePoster = AsyncSignal()
var posted: [Character] = []
let service = ComputerActionService(textGraphemePoster: { grapheme in
posted.append(grapheme)
guard posted.count == 1 else { return }
await firstPosted.signal()
await resumePoster.wait()
})
let action = Task { @MainActor in
try await service.typeTextForTesting("A👨👩👧👦B")
}
await firstPosted.wait()
let release = Task { @MainActor in
await service.releaseHeldInput(lifecycleGeneration: 1)
}
while service.lifecycleGenerationForTesting != 1 {
await Task.yield()
}
await resumePoster.signal()
await release.value
let lifecycleError: Error?
do {
_ = try await action.value
lifecycleError = nil
} catch {
lifecycleError = error
}
#expect(self.isLifecycleChanged(lifecycleError))
#expect(posted == ["A"])
}
@Test func `new lifecycle generation cancels old work before fresh action`() async throws {
let probe = ActionProbe()
let releaseProbe = LifecycleReleaseProbe(allowed: true)
let queue = ComputerActionExecutionQueue(onLifecycleRelease: releaseProbe.attempt)
let oldParams = OpenClawComputerActParams(action: .leftClick, x: 1, y: 0, refWidth: 1280)
let freshParams = OpenClawComputerActParams(action: .leftClick, x: 2, y: 0, refWidth: 1280)
let old = Task { @MainActor in
try await queue.perform(oldParams, lifecycleGeneration: 0, operation: probe.perform)
}
await probe.firstStarted.wait()
let release = Task { @MainActor in
await queue.releaseHeldInput(lifecycleGeneration: 1)
}
await Task.yield()
#expect(releaseProbe.attempts == 1)
await probe.releaseFirst.signal()
await release.value
#expect(releaseProbe.attempts == 2)
let oldError: Error?
do {
_ = try await old.value
oldError = nil
} catch {
oldError = error
}
#expect(self.isLifecycleChanged(oldError))
_ = try await queue.perform(
freshParams,
lifecycleGeneration: 1,
operation: probe.perform)
#expect(probe.enteredActionIDs == [1, 2])
let staleError: Error?
do {
_ = try await queue.perform(
oldParams,
lifecycleGeneration: 0,
operation: probe.perform)
staleError = nil
} catch {
staleError = error
}
#expect(self.isLifecycleChanged(staleError))
#expect(probe.enteredActionIDs == [1, 2])
}
@Test func `failed lifecycle mouse up blocks newer generation until retry succeeds`() async throws {
let probe = ActionProbe()
let releaseProbe = LifecycleReleaseProbe(allowed: false)
let queue = ComputerActionExecutionQueue(onLifecycleRelease: releaseProbe.attempt)
let params = OpenClawComputerActParams(action: .type, x: 2, y: 0, refWidth: 1280)
let action = Task { @MainActor in
try await queue.perform(
params,
lifecycleGeneration: 1,
operation: probe.perform)
}
while releaseProbe.attempts < 2 {
await Task.yield()
}
#expect(probe.enteredActionIDs.isEmpty)
releaseProbe.allowed = true
_ = try await action.value
#expect(releaseProbe.attempts >= 3)
#expect(probe.enteredActionIDs == [2])
}
@Test func `raw click preconstructs up before posting down`() throws {
var factoryCalls = 0
var postCount = 0
let service = ComputerActionService(
mouseEventFactory: { type, point, button, _, _ in
factoryCalls += 1
if factoryCalls == 2 {
throw SyntheticPostError.failed
}
guard let event = CGEvent(
mouseEventSource: nil,
mouseType: type,
mouseCursorPosition: point,
mouseButton: button)
else { throw SyntheticPostError.failed }
return event
},
mouseEventPoster: { _ in postCount += 1 })
#expect(throws: SyntheticPostError.self) {
try service.rawClickForTesting()
}
#expect(factoryCalls == 2)
#expect(postCount == 0)
}
@Test func `raw drag posts release after a move failure`() async {
var eventTypes: [ObjectIdentifier: CGEventType] = [:]
var postedTypes: [CGEventType] = []
var failedMove = false
let service = ComputerActionService(
mouseEventFactory: { type, point, button, _, _ in
guard let event = CGEvent(
mouseEventSource: nil,
mouseType: type,
mouseCursorPosition: point,
mouseButton: button)
else { throw SyntheticPostError.failed }
eventTypes[ObjectIdentifier(event)] = type
return event
},
mouseEventPoster: { event in
let type = eventTypes[ObjectIdentifier(event)]
if let type { postedTypes.append(type) }
if type == .leftMouseDragged, !failedMove {
failedMove = true
throw SyntheticPostError.failed
}
})
do {
try await service.rawDragForTesting()
Issue.record("raw drag unexpectedly succeeded")
} catch is SyntheticPostError {
// Expected injected move failure.
} catch {
Issue.record("unexpected error: \(error)")
}
#expect(postedTypes.first == .leftMouseDown)
#expect(postedTypes.contains(.leftMouseDragged))
#expect(postedTypes.last == .leftMouseUp)
}
@Test func `raw drag posts release when cancelled between moves`() async {
var eventTypes: [ObjectIdentifier: CGEventType] = [:]
var postedTypes: [CGEventType] = []
let service = ComputerActionService(
mouseEventFactory: { type, point, button, _, _ in
guard let event = CGEvent(
mouseEventSource: nil,
mouseType: type,
mouseCursorPosition: point,
mouseButton: button)
else { throw SyntheticPostError.failed }
eventTypes[ObjectIdentifier(event)] = type
return event
},
mouseEventPoster: { event in
if let type = eventTypes[ObjectIdentifier(event)] {
postedTypes.append(type)
}
})
let drag = Task { @MainActor in
try await service.rawDragForTesting()
}
while !postedTypes.contains(.leftMouseDragged) {
await Task.yield()
}
drag.cancel()
_ = try? await drag.value
#expect(postedTypes.first == .leftMouseDown)
#expect(postedTypes.last == .leftMouseUp)
}
}