Files
openclaw/apps/macos/Tests/OpenClawIPCTests/ComputerRefLifecycleContractTests.swift
T
Peter Steinberger 62deb87ee4 fix(computer-use): converge provider ref lifecycles on the contract (#124374)
* 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
2026-08-15 21:08:41 -07:00

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"])
}
}