Files
openclaw/apps/macos/Tests/OpenClawIPCTests/ComputerRefLifecycleContractTests.swift
Peter Steinberger 243f51d314 refactor(macos): name computer-use executors for their scope (#124586)
The computer.act v1 wire contract is gone, but the naming that survived it
still described a version split instead of the real one: screen-coordinate
execution versus window/element-scoped execution. Both are live rungs of the
same ladder.

- Extract the screen-coordinate half of the 1334-line ComputerActionService
  into ComputerScreenActionExecutor (dispatch, typing, scroll, coordinate
  mapping, button-hold watchdog, raw CoreGraphics primitives). Moved code is
  unchanged apart from threading the queue authority check as a parameter
  instead of reaching back into the queue.
- ComputerActionService keeps its name and becomes the coordinator that owns
  the execution queue, the permission probe, and the shared error vocabulary.
- Rename ComputerActionServiceV2 to ComputerWindowActionExecutor, isV2Request
  to isWindowScopedRequest, isComputerActV2Only to isWindowScopedOnly, and
  ComputerActionError.invalidV2Request to .invalidRequest. The emitted
  COMPUTER_INVALID_REQUEST: prefix is unchanged.
- cua-computer: v2-actions.ts becomes window-actions.ts, handleV2Act becomes
  handleWindowAct, and the stale v1Params local in handleDesktopAct becomes
  desktopParams.
- Note at the computer.act idempotency key that its v1 prefix versions the key
  composition, not the wire contract.

Behavior-neutral: no logic edits, no new branches, no changed error strings.
2026-08-16 06:17:31 -07:00

161 lines
6.5 KiB
Swift

import CoreGraphics
import Foundation
import OpenClawKit
import PeekabooAutomationKit
import Testing
@testable import OpenClaw
/// Drives the real `ComputerWindowActionExecutor` reference lifecycle through the same
/// case table the CUA provider runs in `ref-lifecycle.contract.test.ts`, so the two
/// providers cannot drift apart on what an opaque ref means.
@MainActor
struct ComputerRefLifecycleContractTests {
private struct Contract: Decodable {
let staleErrorCode: String
let cases: [Case]
}
private struct Case: Decodable {
let id: String
let scenario: Scenario
let expected: Expected
}
private enum Scenario: String, Decodable {
case freshWindow = "fresh_window"
case freshElement = "fresh_element"
case windowMoved = "window_moved"
case generationRotation = "generation_rotation"
case inFlightGenerationChange = "in_flight_generation_change"
case supersededObservation = "superseded_observation"
case unrelatedDiscovery = "unrelated_discovery"
case unknownRef = "unknown_ref"
}
private enum Expected: String, Decodable {
case valid
case stale
}
private static let app = ServiceApplicationInfo(
processIdentifier: 4321,
processStartIdentity: 90210,
bundleIdentifier: "com.example.contract",
name: "Contract",
windowCount: 1)
private static let originalBounds = CGRect(x: 0, y: 0, width: 400, height: 300)
private static let movedBounds = CGRect(x: 120, y: 80, width: 640, height: 480)
@Test func `Peekaboo satisfies the shared ref lifecycle cases`() async throws {
let contract = try Self.loadContract()
for testCase in contract.cases {
let error = await self.run(testCase)
switch testCase.expected {
case .valid:
#expect(error == nil, "\(testCase.id) unexpectedly failed: \(String(describing: error))")
case .stale:
#expect(
error?.localizedDescription.hasPrefix("\(contract.staleErrorCode):") == true,
"\(testCase.id) returned \(String(describing: error))")
}
}
}
private func run(_ testCase: Case) async -> Error? {
let service = ComputerWindowActionExecutor()
service.adoptLifecycleGeneration(1)
let windowRef = service.issueWindowRef(
app: Self.app,
window: Self.window(windowID: 77, bounds: Self.originalBounds))
let observation = service.issueObservation(
windowRef: windowRef,
snapshotId: "snapshot-1",
elements: [ComputerWindowActionExecutor.ElementTarget(id: "button", bounds: .zero)])
do {
switch testCase.scenario {
case .freshWindow:
_ = try service.resolveWindow(windowRef)
case .freshElement:
let current = try service.resolveObservation(observation.id, windowRef: windowRef)
_ = try service.resolveElement(observation.elementRefs[0], observation: current)
case .windowMoved:
let moved = Self.window(windowID: 77, bounds: Self.movedBounds)
#expect(service.issueWindowRef(app: Self.app, window: moved) == windowRef)
let refreshed = try service.resolveWindow(windowRef).window
#expect(refreshed.bounds == Self.movedBounds)
#expect(refreshed.mutationIdentity == moved.mutationIdentity)
case .generationRotation:
service.adoptLifecycleGeneration(2)
_ = try service.resolveWindow(windowRef)
case .inFlightGenerationChange:
try await Self.performWithRevokedLifecycle(service)
case .supersededObservation:
_ = service.issueObservation(
windowRef: windowRef,
snapshotId: "snapshot-2",
elements: [ComputerWindowActionExecutor.ElementTarget(id: "button", bounds: .zero)])
_ = try service.resolveObservation(observation.id, windowRef: windowRef)
case .unrelatedDiscovery:
_ = service.issueWindowRef(
app: Self.app,
window: Self.window(windowID: 88, bounds: Self.movedBounds))
_ = try service.resolveWindow(windowRef)
case .unknownRef:
_ = try service.resolveWindow("peekaboo:v2:window:unknown")
}
return nil
} catch {
return error
}
}
/// Runs a real action whose lifecycle generation is revoked after the work
/// completed but before the result is released, which is the only way the
/// in-flight change reaches a caller.
private static func performWithRevokedLifecycle(_ service: ComputerWindowActionExecutor) async throws {
var checks = 0
_ = try await service.perform(
OpenClawComputerActParams(action: .getCursorPosition),
lifecycleGeneration: 1,
checkExecutionAllowed: {
checks += 1
if checks > 1 {
throw ComputerActionService.ComputerActionError.lifecycleChanged
}
})
}
private static func window(windowID: Int, bounds: CGRect) -> ServiceWindowInfo {
ServiceWindowInfo(
windowID: windowID,
title: "Contract Window",
bounds: bounds,
mutationIdentity: WindowMutationIdentity(
windowID: windowID,
ownerProcessIdentifier: self.app.processIdentifier,
ownerProcessStartIdentity: 90210,
capturedBounds: bounds))
}
private static func loadContract() throws -> Contract {
var cursor = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
for _ in 0..<8 {
let candidate = cursor
.appendingPathComponent("test")
.appendingPathComponent("fixtures")
.appendingPathComponent("computer-ref-lifecycle-contract.json")
if FileManager.default.fileExists(atPath: candidate.path) {
return try JSONDecoder().decode(Contract.self, from: Data(contentsOf: candidate))
}
cursor.deleteLastPathComponent()
}
throw NSError(
domain: "ComputerRefLifecycleContractTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "missing shared computer ref lifecycle fixture"])
}
}