mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
c84921634d
* fix(macos): avoid passive Automation prompts * fix(macos): keep Voice Wake recognition on device * fix(macos): require consent for activity presence * chore(apps): refresh native i18n inventory * fix(macos): preserve presence clears across gateway versions * fix(macos): prioritize activity privacy opt-out * chore(apps): refresh native i18n inventory * fix(macos): scrub legacy presence activity * fix(macos): migrate permission status caller * fix(macos): preserve unknown permission state * fix(macos): refresh privacy change artifacts * refactor(macos): remove stale presence helper * fix(deps): patch URI and Jaeger advisories * test(gateway): adopt pairing-bound node sessions
362 lines
12 KiB
Swift
362 lines
12 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@MainActor
|
|
struct MacNodePresenceReporterTests {
|
|
@Test func `active computer presence defaults off and honors explicit opt in`() throws {
|
|
let suiteName = "MacNodePresenceReporterTests.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
#expect(!AppState.resolveActiveComputerPresenceEnabled(defaults: defaults))
|
|
defaults.set(true, forKey: activeComputerPresenceEnabledKey)
|
|
#expect(AppState.resolveActiveComputerPresenceEnabled(defaults: defaults))
|
|
}
|
|
|
|
@Test func `only the latest active computer presence update applies`() {
|
|
#expect(AppState.activeComputerPresenceUpdateIsCurrent(
|
|
capturedGeneration: 4,
|
|
currentGeneration: 4,
|
|
capturedEnabled: false,
|
|
currentEnabled: false,
|
|
isCancelled: false))
|
|
#expect(!AppState.activeComputerPresenceUpdateIsCurrent(
|
|
capturedGeneration: 3,
|
|
currentGeneration: 4,
|
|
capturedEnabled: true,
|
|
currentEnabled: false,
|
|
isCancelled: false))
|
|
#expect(!AppState.activeComputerPresenceUpdateIsCurrent(
|
|
capturedGeneration: 4,
|
|
currentGeneration: 4,
|
|
capturedEnabled: false,
|
|
currentEnabled: false,
|
|
isCancelled: true))
|
|
}
|
|
|
|
@Test func `disabled reporter does not sample idle time`() async {
|
|
let idleProbe = PresenceIdleProbe(seconds: 3)
|
|
let sender = PresenceSenderRecorder()
|
|
let clear = PresenceClearRecorder()
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: false,
|
|
idleSecondsProvider: idleProbe.read)
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
for _ in 0..<20 { await Task.yield() }
|
|
reporter.stop()
|
|
|
|
#expect(idleProbe.calls == 0)
|
|
#expect(sender.payloads.isEmpty)
|
|
#expect(clear.calls == 0)
|
|
#expect(clear.unsupportedCalls == 0)
|
|
}
|
|
|
|
@Test func `enabling sends an immediate activity sample`() async throws {
|
|
let idleProbe = PresenceIdleProbe(seconds: 7)
|
|
let sender = PresenceSenderRecorder()
|
|
let clear = PresenceClearRecorder()
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: false,
|
|
idleSecondsProvider: idleProbe.read)
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
await reporter.setReportingEnabled(true)
|
|
reporter.stop()
|
|
|
|
let payload = try #require(sender.payloadObjects.last)
|
|
#expect(payload["idleSeconds"] as? Int == 7)
|
|
#expect(payload["action"] == nil)
|
|
#expect(idleProbe.calls == 1)
|
|
}
|
|
|
|
@Test func `disabling sends a same connection clear`() async throws {
|
|
let sender = PresenceSenderRecorder()
|
|
let clear = PresenceClearRecorder()
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: false,
|
|
idleSecondsProvider: { 0 })
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
await reporter.setReportingEnabled(true)
|
|
await reporter.setReportingEnabled(false)
|
|
reporter.stop()
|
|
|
|
let payload = try #require(sender.payloadObjects.last)
|
|
#expect(payload["idleSeconds"] as? Int == 0)
|
|
#expect(clear.calls == 1)
|
|
#expect(clear.unsupportedCalls == 0)
|
|
}
|
|
|
|
@Test func `failed clear retries while disabled`() async {
|
|
let sender = PresenceSenderRecorder()
|
|
let clear = PresenceClearRecorder(outcomes: [.retry, .cleared])
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: false,
|
|
idleSecondsProvider: { 0 })
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
await reporter.setReportingEnabled(true)
|
|
await reporter.setReportingEnabled(false)
|
|
await reporter.setReportingEnabled(false)
|
|
reporter.stop()
|
|
|
|
#expect(clear.calls == 2)
|
|
#expect(clear.unsupportedCalls == 0)
|
|
}
|
|
|
|
@Test func `activity crossing opt out is followed by a clear`() async {
|
|
let sender = SuspendingPresenceSender()
|
|
let clear = PresenceClearRecorder()
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: true,
|
|
idleSecondsProvider: { 0 })
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
await sender.waitForActivitySend()
|
|
|
|
await reporter.setReportingEnabled(false)
|
|
sender.finishActivitySend()
|
|
await clear.waitForCallCount(1)
|
|
reporter.stop()
|
|
|
|
#expect(clear.calls == 1)
|
|
#expect(clear.unsupportedCalls == 0)
|
|
}
|
|
|
|
@Test func `activity crossing disable and re-enable refreshes the enabled sample`() async {
|
|
let sender = SuspendingPresenceSender()
|
|
let clear = PresenceClearRecorder()
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: true,
|
|
idleSecondsProvider: { 0 })
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
await sender.waitForActivitySend()
|
|
|
|
await reporter.setReportingEnabled(false)
|
|
await reporter.setReportingEnabled(true)
|
|
sender.finishActivitySend()
|
|
await sender.waitForActivityCount(3)
|
|
reporter.stop()
|
|
|
|
#expect(sender.payloadObjects.last?["idleSeconds"] as? Int == 0)
|
|
#expect(clear.calls == 0)
|
|
}
|
|
|
|
@Test func `unsupported clear requests one reconnect and fresh disabled start stays silent`() async {
|
|
let sender = PresenceSenderRecorder()
|
|
let clear = PresenceClearRecorder(outcomes: [.unsupported])
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: false,
|
|
idleSecondsProvider: { 0 })
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
|
|
await reporter.setReportingEnabled(true)
|
|
await reporter.setReportingEnabled(false)
|
|
await reporter.setReportingEnabled(false)
|
|
reporter.stop()
|
|
reporter.start(
|
|
sender: sender.send,
|
|
clearer: clear.clear,
|
|
onUnsupportedClear: clear.handleUnsupported)
|
|
for _ in 0..<20 { await Task.yield() }
|
|
reporter.stop()
|
|
|
|
#expect(sender.payloadObjects.filter { $0["idleSeconds"] != nil }.count == 1)
|
|
#expect(clear.calls == 1)
|
|
#expect(clear.unsupportedCalls == 1)
|
|
}
|
|
|
|
@Test func `stale activity completion cannot attach to a restarted disabled route`() async {
|
|
let staleSender = SuspendingPresenceSender()
|
|
let staleClear = PresenceClearRecorder()
|
|
let reporter = MacNodePresenceReporter(
|
|
reportingEnabled: true,
|
|
idleSecondsProvider: { 0 })
|
|
reporter.start(
|
|
sender: staleSender.send,
|
|
clearer: staleClear.clear,
|
|
onUnsupportedClear: staleClear.handleUnsupported)
|
|
await staleSender.waitForActivitySend()
|
|
|
|
reporter.stop()
|
|
await reporter.setReportingEnabled(false)
|
|
let freshSender = PresenceSenderRecorder()
|
|
let freshClear = PresenceClearRecorder(outcomes: [.unsupported])
|
|
reporter.start(
|
|
sender: freshSender.send,
|
|
clearer: freshClear.clear,
|
|
onUnsupportedClear: freshClear.handleUnsupported)
|
|
staleSender.finishActivitySend()
|
|
for _ in 0..<20 { await Task.yield() }
|
|
reporter.stop()
|
|
|
|
#expect(freshSender.payloads.isEmpty)
|
|
#expect(freshClear.calls == 0)
|
|
#expect(freshClear.unsupportedCalls == 0)
|
|
}
|
|
|
|
@Test func `first activity sample sends immediately`() {
|
|
#expect(MacNodePresenceReporter._testShouldSend(
|
|
idleSeconds: 0,
|
|
nowMs: 100_000,
|
|
lastSentAtMs: nil,
|
|
lastSentActiveAtMs: nil))
|
|
}
|
|
|
|
@Test func `continuous activity is throttled and then refreshed`() {
|
|
#expect(!MacNodePresenceReporter._testShouldSend(
|
|
idleSeconds: 0,
|
|
nowMs: 110_000,
|
|
lastSentAtMs: 100_000,
|
|
lastSentActiveAtMs: 100_000))
|
|
#expect(MacNodePresenceReporter._testShouldSend(
|
|
idleSeconds: 0,
|
|
nowMs: 115_000,
|
|
lastSentAtMs: 100_000,
|
|
lastSentActiveAtMs: 100_000))
|
|
}
|
|
|
|
@Test func `idle presence gets a sparse keepalive`() {
|
|
#expect(!MacNodePresenceReporter._testShouldSend(
|
|
idleSeconds: 100,
|
|
nowMs: 200_000,
|
|
lastSentAtMs: 100_000,
|
|
lastSentActiveAtMs: 100_000))
|
|
#expect(!MacNodePresenceReporter._testShouldSend(
|
|
idleSeconds: 2_592_000,
|
|
nowMs: 115_000,
|
|
lastSentAtMs: 100_000,
|
|
lastSentActiveAtMs: 100_000,
|
|
saturated: true))
|
|
#expect(MacNodePresenceReporter._testShouldSend(
|
|
idleSeconds: 180,
|
|
nowMs: 280_000,
|
|
lastSentAtMs: 100_000,
|
|
lastSentActiveAtMs: 100_000))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class PresenceIdleProbe {
|
|
let seconds: Int
|
|
private(set) var calls = 0
|
|
|
|
init(seconds: Int) {
|
|
self.seconds = seconds
|
|
}
|
|
|
|
func read() -> Int? {
|
|
self.calls += 1
|
|
return self.seconds
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class PresenceSenderRecorder {
|
|
private(set) var payloads: [String] = []
|
|
|
|
var payloadObjects: [[String: Any]] {
|
|
self.payloads.compactMap { payload in
|
|
guard let data = payload.data(using: .utf8) else { return nil }
|
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
}
|
|
}
|
|
|
|
func send(_: String, _ payload: String) async -> Bool {
|
|
self.payloads.append(payload)
|
|
return true
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class PresenceClearRecorder {
|
|
private var outcomes: [MacNodePresenceReporter.ClearDeliveryResult]
|
|
private(set) var calls = 0
|
|
private(set) var unsupportedCalls = 0
|
|
|
|
init(outcomes: [MacNodePresenceReporter.ClearDeliveryResult] = []) {
|
|
self.outcomes = outcomes
|
|
}
|
|
|
|
func clear() async -> MacNodePresenceReporter.ClearDeliveryResult {
|
|
self.calls += 1
|
|
return self.outcomes.isEmpty ? .cleared : self.outcomes.removeFirst()
|
|
}
|
|
|
|
func handleUnsupported() {
|
|
self.unsupportedCalls += 1
|
|
}
|
|
|
|
func waitForCallCount(_ expected: Int) async {
|
|
for _ in 0..<1000 {
|
|
if self.calls >= expected { return }
|
|
await Task.yield()
|
|
}
|
|
Issue.record("timed out waiting for \(expected) presence clear calls")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SuspendingPresenceSender {
|
|
private var activityContinuation: CheckedContinuation<Bool, Never>?
|
|
private var hasSuspendedActivity = false
|
|
private(set) var payloads: [String] = []
|
|
|
|
var payloadObjects: [[String: Any]] {
|
|
self.payloads.compactMap { payload in
|
|
guard let data = payload.data(using: .utf8) else { return nil }
|
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
}
|
|
}
|
|
|
|
func send(_: String, _ payload: String) async -> Bool {
|
|
self.payloads.append(payload)
|
|
guard self.payloadObjects.last?["idleSeconds"] != nil, !self.hasSuspendedActivity else {
|
|
return true
|
|
}
|
|
self.hasSuspendedActivity = true
|
|
return await withCheckedContinuation { continuation in
|
|
self.activityContinuation = continuation
|
|
}
|
|
}
|
|
|
|
func waitForActivitySend() async {
|
|
for _ in 0..<1000 {
|
|
if self.activityContinuation != nil { return }
|
|
await Task.yield()
|
|
}
|
|
Issue.record("timed out waiting for suspended activity send")
|
|
}
|
|
|
|
func finishActivitySend() {
|
|
self.activityContinuation?.resume(returning: true)
|
|
self.activityContinuation = nil
|
|
}
|
|
|
|
func waitForActivityCount(_ expected: Int) async {
|
|
for _ in 0..<1000 {
|
|
if self.payloadObjects.filter({ $0["idleSeconds"] != nil }).count >= expected { return }
|
|
await Task.yield()
|
|
}
|
|
Issue.record("timed out waiting for \(expected) activity samples")
|
|
}
|
|
}
|