mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 21:26:03 +00:00
716 lines
25 KiB
Swift
716 lines
25 KiB
Swift
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)
|
||
}
|
||
}
|