mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
243f51d314
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.
161 lines
6.5 KiB
Swift
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"])
|
|
}
|
|
}
|