Files
程序员阿江(Relakkes) 8751e558e5 fix(computer-use): preserve background input across focus changes
Track real and synthetic focus with process-bound monitor receipts.
Bind keyboard activation to the snapshot window and align targeted
pointer and keyboard bursts with the reference event sequence.
Wait for pasteboard data delivery before restoring the clipboard,
and add focus/stream diagnostics with transition regression coverage.

Validated with check:native and six covered NetEase focus cycles.
2026-08-31 23:03:54 +08:00

514 lines
24 KiB
Swift

import AppKit
import os
import XCTest
@testable import cc_haha_computer_use
/// Only the OS observations and event transport are substituted. Each test
/// drives the production coordinator and, where relevant, the real observer.
@MainActor
final class FocusLifecycleIntegrationTests: XCTestCase {
private let targetPID: pid_t = 42
func testClickTypeAndReturnShareOneAcceptedFocusEstablishment() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
for action in ["click", "type_text", "Return"] {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction(action)
}
XCTAssertEqual(external.snapshot.posts, ["activate"])
XCTAssertEqual(external.snapshot.actions, ["click", "type_text", "Return"])
}
func testLostFocusAfterSuccessIsRestoredBeforeTheNextAction() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction("click")
external.update { $0.hasFocus = false; $0.acceptsInput = false }
coordinator.observeFocus(pid: targetPID, hasFocus: false)
for action in ["type_text", "Return"] {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction(action)
}
XCTAssertEqual(external.snapshot.posts, ["activate", "returnFocus"])
XCTAssertEqual(external.snapshot.actions, ["click", "type_text", "Return"])
}
func testAnAlreadyFocusedApplicationReceivesNoEstablishment() async throws {
for active in [false, true] {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
external.update { $0.isActive = active; $0.hasFocus = true; $0.acceptsInput = true }
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
XCTAssertTrue(external.snapshot.posts.isEmpty)
}
}
func testAnActiveApplicationWithoutFocusNeedsOnlyFocusReturned() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
external.update { $0.isActive = true }
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["returnFocus"])
}
func testRealActivationThenCoverWithoutAnIntermediateCURequestReestablishesInput() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let center = NotificationCenter()
let observer = SyntheticWindowFocus.ApplicationLifecycleObserver(center: center, coordinator: coordinator)
defer { withExtendedLifetime(observer) {} }
let application = FocusNotificationApplication()
let pid = application.processIdentifier
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
external.update { $0.isActive = true }
center.post(name: NSWorkspace.didActivateApplicationNotification, object: nil,
userInfo: [NSWorkspace.applicationUserInfoKey: application])
external.update { $0.isActive = false; $0.hasFocus = false; $0.acceptsInput = false }
center.post(name: NSWorkspace.didDeactivateApplicationNotification, object: nil,
userInfo: [NSWorkspace.applicationUserInfoKey: application])
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
external.recordAction("search-after-cover")
XCTAssertEqual(external.snapshot.posts, ["activate", "activate"])
XCTAssertEqual(external.snapshot.actions, ["search-after-cover"])
}
func testLateActivationNotificationIsNotDroppedBecauseTheTargetIsAlreadyCovered() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let center = NotificationCenter()
let observer = SyntheticWindowFocus.ApplicationLifecycleObserver(center: center, coordinator: coordinator)
defer { withExtendedLifetime(observer) {} }
let application = FocusNotificationApplication()
let pid = application.processIdentifier
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
// Activation arrives after the target is already covered. The next
// request observes deactivation even if its notification is still
// queued; dropping this activation would lose the entire transition.
external.update { $0.isActive = false; $0.hasFocus = false; $0.acceptsInput = false }
center.post(name: NSWorkspace.didActivateApplicationNotification, object: nil,
userInfo: [NSWorkspace.applicationUserInfoKey: application])
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["activate", "activate"])
}
func testUnrelatedAndMalformedLifecycleNotificationsDoNotResetAcceptedFocus() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let center = NotificationCenter()
let observer = SyntheticWindowFocus.ApplicationLifecycleObserver(center: center, coordinator: coordinator)
defer { withExtendedLifetime(observer) {} }
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
center.post(name: NSWorkspace.didActivateApplicationNotification, object: nil,
userInfo: [NSWorkspace.applicationUserInfoKey: "not an application"])
let unrelatedApplication = FocusNotificationApplication(pid: 43)
center.post(name: NSWorkspace.didDeactivateApplicationNotification, object: nil,
userInfo: [NSWorkspace.applicationUserInfoKey: unrelatedApplication])
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["activate"])
}
func testTimeoutCannotAuthorizeAnActionAndTheNextAttemptCanRecover() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
external.update { $0.acceptOnPost = false }
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction("must-not-run")
XCTFail("unacknowledged activation must not authorize input")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_not_accepted")
}
XCTAssertTrue(external.snapshot.actions.isEmpty)
XCTAssertEqual(external.snapshot.posts, ["activate"])
external.update { $0.acceptOnPost = true }
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction("recovered")
XCTAssertEqual(external.snapshot.posts, ["activate", "activate"])
XCTAssertEqual(external.snapshot.actions, ["recovered"])
}
func testCachedFocusStillRequiresTheTargetToAcceptInput() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.update { $0.acceptsInput = false }
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction("must-not-run")
XCTFail("a cached belief is not an AXFrontmost acknowledgement")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_not_accepted")
}
XCTAssertTrue(external.snapshot.actions.isEmpty)
XCTAssertEqual(external.snapshot.posts, ["activate"])
}
func testFailedEventConstructionDoesNotCommitBeliefAndCanRetry() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
external.update { $0.postSucceeds = false }
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.recordAction("must-not-run")
XCTFail("a failed activation must not authorize input")
} catch {}
XCTAssertTrue(external.snapshot.actions.isEmpty)
external.update { $0.postSucceeds = true }
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["activate", "activate"])
}
func testProcessReplacementDuringAcceptanceWaitCannotInheritFocus() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
external.update { $0.acceptOnPost = false }
let runtime = external.runtime(onPause: {
external.update {
$0.identity = FocusExternalRuntime.identity(launchTime: 2)
$0.hasFocus = true
$0.acceptsInput = true
}
})
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: runtime)
external.recordAction("must-not-run")
XCTFail("the new process must not inherit an in-flight activation")
} catch let error as CUError {
XCTAssertEqual(error.code, "stale_process")
}
XCTAssertTrue(external.snapshot.actions.isEmpty)
}
func testAPIDReusedBetweenRequestsEstablishesFocusForTheNewLifetime() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.update {
$0.identity = FocusExternalRuntime.identity(launchTime: 2)
$0.hasFocus = false
$0.acceptsInput = false
}
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["activate", "activate"])
}
func testAConfirmationNotificationDuringTheWaitDoesNotInvalidateAcceptance() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
external.update { $0.acceptOnPost = false }
let pid = targetPID
let runtime = external.runtime(onPause: {
coordinator.observeFocus(pid: pid, hasFocus: true)
external.update { $0.hasFocus = true; $0.acceptsInput = true }
})
try await coordinator.prepare(pid: pid, window: nil, runtime: runtime)
external.recordAction("accepted")
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["activate"])
XCTAssertEqual(external.snapshot.actions, ["accepted"])
}
func testFocusLossDuringRecoveryRejectsALateAcceptanceAndAllowsRetry() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
external.update { $0.hasFocus = false; $0.acceptsInput = false; $0.acceptOnPost = false }
coordinator.observeFocus(pid: targetPID, hasFocus: false)
let pid = targetPID
let runtime = external.runtime(onPause: {
coordinator.observeFocus(pid: pid, hasFocus: false)
external.update { $0.hasFocus = true; $0.acceptsInput = true }
})
do {
try await coordinator.prepare(pid: pid, window: nil, runtime: runtime)
external.recordAction("must-not-run")
XCTFail("a later focus loss must invalidate the activation receipt")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_changed")
}
XCTAssertTrue(external.snapshot.actions.isEmpty)
external.update { $0.hasFocus = false; $0.acceptsInput = false; $0.acceptOnPost = true }
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
external.recordAction("retry")
XCTAssertEqual(external.snapshot.actions, ["retry"])
XCTAssertEqual(external.snapshot.posts.count, 3)
}
func testSessionDrainRequiresANewEstablishment() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
_ = coordinator.drain()
external.update { $0.hasFocus = false; $0.acceptsInput = false }
try await coordinator.prepare(pid: targetPID, window: nil, runtime: external.runtime())
XCTAssertEqual(external.snapshot.posts, ["activate", "activate"])
}
func testLostMonitorAfterRegistrationBeforeBeliefCreationCannotAuthorizeInput() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let stream = FocusLifecycleStream()
let monitor = try registeredMonitor(stream: stream, coordinator: coordinator)
let runtime = protectedRuntime(external: external, monitor: monitor)
stream.interrupt()
external.update { $0.acceptsInput = true }
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: runtime)
external.recordAction("must-not-run")
XCTFail("lost observation cannot be replaced by a stale AXFrontmost value")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_monitor_interrupted")
}
XCTAssertTrue(external.snapshot.posts.isEmpty)
XCTAssertTrue(external.snapshot.actions.isEmpty)
}
func testMonitorLossWhileReadingInitialIdentityCannotPostActivation() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let stream = FocusLifecycleStream()
let monitor = try registeredMonitor(stream: stream, coordinator: coordinator)
var runtime = protectedRuntime(external: external, monitor: monitor)
runtime.identity = { _ in
stream.interrupt()
return external.snapshot.identity
}
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: runtime)
XCTFail("the pre-post continuity check must catch loss after the entry check")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_monitor_interrupted")
}
XCTAssertTrue(external.snapshot.posts.isEmpty)
}
func testMonitorLossDuringTheWaitWithholdsInputEvenIfAXAcknowledges() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let stream = FocusLifecycleStream()
let monitor = try registeredMonitor(stream: stream, coordinator: coordinator)
var runtime = protectedRuntime(external: external, monitor: monitor)
runtime.pause = {
stream.interrupt()
external.update { $0.acceptsInput = true }
}
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: runtime)
external.recordAction("must-not-run")
XCTFail("an acknowledgement after monitor loss is not safe to act on")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_monitor_interrupted")
}
XCTAssertEqual(external.snapshot.posts, ["activate"])
XCTAssertTrue(external.snapshot.actions.isEmpty)
}
func testMonitorLossDuringAcknowledgementCannotCommitTheReceipt() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let stream = FocusLifecycleStream()
let monitor = try registeredMonitor(stream: stream, coordinator: coordinator)
var runtime = protectedRuntime(external: external, monitor: monitor)
runtime.acceptsInput = { _ in
stream.interrupt()
return true
}
do {
try await coordinator.prepare(pid: targetPID, window: nil, runtime: runtime)
external.recordAction("must-not-run")
XCTFail("the receipt needs a final continuity check after reading AX")
} catch let error as CUError {
XCTAssertEqual(error.code, "focus_monitor_interrupted")
}
XCTAssertTrue(external.snapshot.actions.isEmpty)
}
func testAlreadyCancelledPreparationNeverPostsFocusEvents() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let external = FocusExternalRuntime()
let pid = targetPID
let task = Task { @MainActor in
withUnsafeCurrentTask { $0?.cancel() }
try await coordinator.prepare(pid: pid, window: nil, runtime: external.runtime())
external.recordAction("must-not-run")
}
do {
try await task.value
XCTFail("cancelled preparation must not post activation")
} catch is CancellationError {}
XCTAssertTrue(external.snapshot.posts.isEmpty)
XCTAssertTrue(external.snapshot.actions.isEmpty)
}
func testDetachedCallerRunsAllOSObservationAndPostingCallbacksOnMainActor() async throws {
let coordinator = SyntheticWindowFocus.Coordinator()
let calls = OSAllocatedUnfairLock(initialState: Set<String>())
let runtime = SyntheticWindowFocus.Runtime(
identity: { _ in
MainActor.assertIsolated()
calls.withLock { _ = $0.insert("identity") }
return FocusExternalRuntime.identity(launchTime: 1)
},
isActive: { _ in
MainActor.assertIsolated()
calls.withLock { _ = $0.insert("isActive") }
return false
},
hasFocus: { _ in
MainActor.assertIsolated()
calls.withLock { _ = $0.insert("hasFocus") }
return false
},
acceptsInput: { _ in
MainActor.assertIsolated()
calls.withLock { _ = $0.insert("acceptsInput") }
return true
},
post: { _, _, _ in
MainActor.assertIsolated()
calls.withLock { _ = $0.insert("post") }
return true
},
pause: { await Task.yield() },
validateContinuity: {
MainActor.assertIsolated()
calls.withLock { _ = $0.insert("continuity") }
}
)
let pid = targetPID
try await Task.detached {
try await coordinator.prepare(pid: pid, window: nil, runtime: runtime)
}.value
XCTAssertEqual(calls.withLock { $0 }, ["identity", "isActive", "hasFocus", "acceptsInput", "post", "continuity"])
}
private func registeredMonitor(
stream: FocusLifecycleStream,
coordinator: SyntheticWindowFocus.Coordinator
) throws -> FocusEventMonitor {
let monitor = FocusEventMonitor(
helperPID: 700, readInitialFocus: { 9 }, isFocusObserver: { _ in false },
makeStream: { stream }, releaseFocus: { _ in true },
readRealFrontmost: { 9 }, isOrdinaryApp: { _ in true },
readProcessIdentity: { .init(executablePath: "/test/\($0)", launchTime: 1) }
)
let pid = targetPID
XCTAssertTrue(monitor.register(pid: pid) { coordinator.observeFocus(pid: pid, hasFocus: $0) })
return monitor
}
private func protectedRuntime(
external: FocusExternalRuntime, monitor: FocusEventMonitor
) -> SyntheticWindowFocus.Runtime {
let continuity = monitor.diagnostic.continuityGeneration
var runtime = external.runtime()
runtime.validateContinuity = {
let diagnostic = monitor.diagnostic
guard diagnostic.available, diagnostic.continuityGeneration == continuity else {
throw CUError("focus_monitor_interrupted", "Focus observation was interrupted")
}
}
return runtime
}
}
private final class FocusLifecycleStream: FocusEventMonitor.Stream, @unchecked Sendable {
private let interruption = OSAllocatedUnfairLock<(@Sendable (String) -> Void)?>(initialState: nil)
func start(
receive: @escaping @Sendable (FocusEventMonitor.Event) -> FocusEventMonitor.Disposition,
interrupted: @escaping @Sendable (String) -> Void
) -> Bool {
interruption.withLock { $0 = interrupted }
return true
}
func addProtectedPID(_ pid: pid_t) -> Bool { true }
func stop() {}
func interrupt() { interruption.withLock { $0 }?("test_interruption") }
}
/// Command-line XCTest is not necessarily a LaunchServices application, so
/// NSRunningApplication.current can report -1. Keep the real payload type and
/// production observer without registering a GUI application during a test.
private final class FocusNotificationApplication: NSRunningApplication, @unchecked Sendable {
let fixturePID: pid_t
init(pid: pid_t = 42) {
fixturePID = pid
super.init()
}
override var processIdentifier: pid_t { fixturePID }
}
private final class FocusExternalRuntime: @unchecked Sendable {
struct Snapshot: Sendable {
var identity: AXTreeProcessIdentity? = FocusExternalRuntime.identity(launchTime: 1)
var isActive = false
var hasFocus = false
var acceptsInput = false
var acceptOnPost = true
var postSucceeds = true
var posts: [String] = []
var actions: [String] = []
}
private let state = OSAllocatedUnfairLock(initialState: Snapshot())
var snapshot: Snapshot { state.withLock { $0 } }
static func identity(launchTime: TimeInterval) -> AXTreeProcessIdentity {
AXTreeProcessIdentity(
bundleID: "com.example.focus-target",
executablePath: "/Applications/FocusTarget.app/Contents/MacOS/FocusTarget",
launchTime: launchTime
)
}
func update(_ mutation: @Sendable (inout Snapshot) -> Void) { state.withLock(mutation) }
func recordAction(_ action: String) { state.withLock { $0.actions.append(action) } }
func runtime(
attempts: Int = 3,
onPause: @escaping @Sendable () async throws -> Void = {}
) -> SyntheticWindowFocus.Runtime {
SyntheticWindowFocus.Runtime(
identity: { [self] _ in snapshot.identity },
isActive: { [self] _ in snapshot.isActive },
hasFocus: { [self] _ in snapshot.hasFocus },
acceptsInput: { [self] _ in snapshot.acceptsInput },
post: { [self] establishment, _, _ in
state.withLock { state in
switch establishment {
case .activate: state.posts.append("activate")
case .returnFocus: state.posts.append("returnFocus")
case .none: state.posts.append("none")
}
guard state.postSucceeds else { return false }
if state.acceptOnPost { state.hasFocus = true; state.acceptsInput = true }
return true
}
},
pause: onPause,
attempts: attempts
)
}
}