mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
62deb87ee4
* fix(computer-use): converge provider ref lifecycles * test(computer-use): expect stale lifecycle code * fix(computer-use): key native window refs on stable window identity Peekaboo matched an existing window ref on the whole WindowMutationIdentity, which embeds capturedBounds and isMinimized. Any move, resize, or minimize therefore minted a new ref for the same live window, never evicted the old one, and let the old ref keep resolving to a stale ServiceWindowInfo whose identity and bounds were then used as the per-action expectations, so the operator saw a downstream mismatch instead of COMPUTER_STALE_OBSERVATION. Window refs now key on stable identity only (WindowServer id plus the owner process generation that guards pid reuse) and refresh the stored target in place on every discovery, so one live window keeps one ref for the whole lifecycle generation and later checks compare against current data. The one-instantiation generic reference store is folded back into the service as plain state plus small concrete helpers. The shared case table gains window_moved, which both providers must satisfy, and the Swift side now drives the real ComputerActionServiceV2 with real Peekaboo values instead of a toy store with an equality matcher. Its in-flight generation case runs a real perform() whose lifecycle is revoked mid-action rather than throwing the error it asserted. * test(cua-computer): drive the real in-flight generation-rotation path
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 `ComputerActionServiceV2` 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 = ComputerActionServiceV2()
|
|
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: [ComputerActionServiceV2.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: [ComputerActionServiceV2.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: ComputerActionServiceV2) 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"])
|
|
}
|
|
}
|