mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
0dbdf994b3
* feat(macos): isolate named app profiles * refactor(macos): isolate profile launch ownership * fix(macos): avoid overlapping approvals socket access * fix(macos): declare profile defaults concurrency ownership * fix(macos): return profiled node launch arguments * chore(i18n): refresh macOS profile source inventory * fix(macos): gate profile startup before services * test(macos): evaluate profile state before assertions * fix(daemon): skip absent launchd deactivation * fix(macos): fail closed on profile port conflicts * chore(i18n): refresh profile conflict inventory * fix(macos): ignore non-gateway launch agent claims * test(macos): stabilize profile lifecycle timing * fix(macos): remove stale dashboard URL * chore(macos): refresh native source baseline
590 lines
27 KiB
Swift
590 lines
27 KiB
Swift
import Foundation
|
|
import OpenClawChatUI
|
|
import OpenClawKit
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct UpdateOrchestrationTests {
|
|
@MainActor
|
|
@Test func `post update performs no service work under active profile`() {
|
|
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
|
NodeServiceManager._testResetPersistentServiceCalls()
|
|
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
|
|
|
#expect(!PostUpdateController.shared.startIfNeeded(profile: profile))
|
|
let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot()
|
|
#expect(snapshot.commands.isEmpty)
|
|
#expect(snapshot.ownershipReads == 0)
|
|
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
|
}
|
|
|
|
@Test func `Sparkle receipt appears only after the target app launches`() throws {
|
|
let suite = "UpdateOrchestrationTests.post-update.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let now = Date(timeIntervalSince1970: 1_720_000_000)
|
|
|
|
PostAppUpdateReceiptStore.record(
|
|
fromVersion: "2026.7.3",
|
|
toVersion: "2026.7.4",
|
|
defaults: defaults,
|
|
now: now)
|
|
|
|
#expect(PostAppUpdateReceiptStore.pending(
|
|
currentVersion: "2026.7.3",
|
|
defaults: defaults) == nil)
|
|
#expect(PostAppUpdateReceiptStore.pending(
|
|
currentVersion: "2026.7.4",
|
|
defaults: defaults) == PostAppUpdateReceipt(
|
|
fromVersion: "2026.7.3",
|
|
toVersion: "2026.7.4",
|
|
recordedAt: now))
|
|
let receipt = try #require(PostAppUpdateReceiptStore.pending(
|
|
currentVersion: "2026.7.4",
|
|
defaults: defaults))
|
|
let incomplete = PostAppUpdateReceiptStore.setGatewayUpdateIncomplete(
|
|
true,
|
|
receipt: receipt,
|
|
defaults: defaults)
|
|
#expect(incomplete.gatewayUpdateIncomplete)
|
|
#expect(PostAppUpdateReceiptStore.pending(
|
|
currentVersion: "2026.7.4",
|
|
defaults: defaults) == incomplete)
|
|
let completed = PostAppUpdateReceiptStore.setGatewayUpdateIncomplete(
|
|
false,
|
|
receipt: incomplete,
|
|
defaults: defaults)
|
|
let inFlight = PostAppUpdateReceiptStore.setNotificationInFlight(
|
|
true,
|
|
receipt: completed,
|
|
defaults: defaults)
|
|
#expect(inFlight.notificationInFlight)
|
|
#expect(!PostUpdateController.isNotificationOnlyRetry(inFlight))
|
|
let readyToRetry = PostAppUpdateReceiptStore.setNotificationInFlight(
|
|
false,
|
|
receipt: inFlight,
|
|
defaults: defaults)
|
|
let firstNotificationFailure = PostAppUpdateReceiptStore.recordNotificationFailure(
|
|
receipt: readyToRetry,
|
|
defaults: defaults)
|
|
#expect(firstNotificationFailure.notificationAttempts == 1)
|
|
#expect(!firstNotificationFailure.gatewayUpdateIncomplete)
|
|
#expect(!firstNotificationFailure.notificationInFlight)
|
|
let finalNotificationFailure = PostAppUpdateReceiptStore.recordNotificationFailure(
|
|
receipt: firstNotificationFailure,
|
|
defaults: defaults)
|
|
#expect(finalNotificationFailure.notificationAttempts == PostAppUpdateReceiptStore.notificationRetryLimit)
|
|
#expect(PostUpdateController.isNotificationOnlyRetry(firstNotificationFailure))
|
|
#expect(!PostUpdateController.isNotificationOnlyRetry(incomplete))
|
|
PostAppUpdateReceiptStore.clear(defaults: defaults)
|
|
#expect(PostAppUpdateReceiptStore.pending(
|
|
currentVersion: "2026.7.4",
|
|
defaults: defaults) == nil)
|
|
}
|
|
|
|
@Test func `launch transition bootstraps upgrades but not fresh onboarding`() throws {
|
|
let suite = "UpdateOrchestrationTests.launch-transition.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let now = Date(timeIntervalSince1970: 1_720_000_000)
|
|
|
|
PostAppUpdateReceiptStore.record(
|
|
fromVersion: "2026.7.3",
|
|
toVersion: "2026.7.4",
|
|
defaults: defaults,
|
|
now: now)
|
|
#expect(PostAppUpdateReceiptStore.pendingForLaunch(
|
|
currentVersion: "2026.7.4",
|
|
onboardingSeen: false,
|
|
defaults: defaults,
|
|
now: now) == nil)
|
|
#expect(defaults.string(forKey: lastLaunchedAppVersionKey) == "2026.7.4")
|
|
#expect(PostAppUpdateReceiptStore.pending(
|
|
currentVersion: "2026.7.4",
|
|
defaults: defaults) == nil)
|
|
#expect(PostAppUpdateReceiptStore.pendingForLaunch(
|
|
currentVersion: "2026.7.4",
|
|
onboardingSeen: true,
|
|
defaults: defaults,
|
|
now: now) == nil)
|
|
|
|
defaults.removeObject(forKey: lastLaunchedAppVersionKey)
|
|
#expect(PostAppUpdateReceiptStore.pendingForLaunch(
|
|
currentVersion: "2026.7.5-dev",
|
|
onboardingSeen: true,
|
|
allowsUpdateWorkflow: false,
|
|
defaults: defaults,
|
|
now: now) == nil)
|
|
#expect(defaults.string(forKey: lastLaunchedAppVersionKey) == "2026.7.5-dev")
|
|
|
|
defaults.removeObject(forKey: lastLaunchedAppVersionKey)
|
|
let bootstrap = try #require(PostAppUpdateReceiptStore.pendingForLaunch(
|
|
currentVersion: "2026.7.5",
|
|
onboardingSeen: true,
|
|
defaults: defaults,
|
|
now: now))
|
|
#expect(bootstrap == PostAppUpdateReceipt(
|
|
fromVersion: "unknown",
|
|
toVersion: "2026.7.5",
|
|
recordedAt: now))
|
|
|
|
PostAppUpdateReceiptStore.clear(defaults: defaults)
|
|
defaults.set("2026.7.5", forKey: lastLaunchedAppVersionKey)
|
|
let transition = try #require(PostAppUpdateReceiptStore.pendingForLaunch(
|
|
currentVersion: "2026.7.6",
|
|
onboardingSeen: true,
|
|
defaults: defaults,
|
|
now: now))
|
|
#expect(transition.fromVersion == "2026.7.5")
|
|
#expect(transition.toVersion == "2026.7.6")
|
|
}
|
|
|
|
@Test func `post-update notice pages to latest direct top-level interaction`() async throws {
|
|
var requestedOffsets: [Int] = []
|
|
let selected = try #require(try await PostUpdateController.preferredNotificationSession(loadPage: { offset in
|
|
requestedOffsets.append(offset)
|
|
if offset == 0 {
|
|
return PostUpdateSessionsResponse(
|
|
sessions: [
|
|
PostUpdateSession(
|
|
key: "agent:main:group:release",
|
|
kind: "group",
|
|
lastChannel: "webchat",
|
|
lastInteractionAt: 500,
|
|
spawnedBy: nil,
|
|
parentSessionKey: nil),
|
|
PostUpdateSession(
|
|
key: "agent:main:telegram:direct:other-user",
|
|
kind: "direct",
|
|
lastChannel: "telegram",
|
|
lastInteractionAt: 450,
|
|
spawnedBy: nil,
|
|
parentSessionKey: nil),
|
|
PostUpdateSession(
|
|
key: "agent:main:subagent:child",
|
|
kind: "direct",
|
|
lastChannel: "webchat",
|
|
lastInteractionAt: 400,
|
|
spawnedBy: "agent:main:main",
|
|
parentSessionKey: "agent:main:main"),
|
|
],
|
|
nextOffset: 2)
|
|
}
|
|
return PostUpdateSessionsResponse(
|
|
sessions: [
|
|
PostUpdateSession(
|
|
key: "agent:main:main",
|
|
kind: "direct",
|
|
lastChannel: "webchat",
|
|
lastInteractionAt: 300,
|
|
spawnedBy: nil,
|
|
parentSessionKey: nil),
|
|
],
|
|
nextOffset: nil)
|
|
}))
|
|
|
|
#expect(selected.key == "agent:main:main")
|
|
#expect(requestedOffsets == [0, 2])
|
|
}
|
|
|
|
@Test func `post-update receipt survives transient notification failure`() {
|
|
#expect(PostUpdateNotificationOutcome.delivered.consumesReceipt)
|
|
#expect(PostUpdateNotificationOutcome.noEligibleSession.consumesReceipt)
|
|
#expect(PostUpdateNotificationOutcome.deliveryUnconfirmed.consumesReceipt)
|
|
#expect(PostUpdateNotificationOutcome.skippedUnsupportedGateway.consumesReceipt)
|
|
#expect(PostUpdateNotificationOutcome.skippedWhilePaused.consumesReceipt)
|
|
#expect(!PostUpdateNotificationOutcome.retryLater.consumesReceipt)
|
|
}
|
|
|
|
@Test func `post-update notification requires a compatible remote Gateway`() {
|
|
#expect(PostUpdateController.supportsPostUpdateNotification(
|
|
gatewayVersion: "2026.7.4",
|
|
appVersion: "2026.7.4"))
|
|
#expect(PostUpdateController.supportsPostUpdateNotification(
|
|
gatewayVersion: "2026.7.5",
|
|
appVersion: "2026.7.4"))
|
|
#expect(!PostUpdateController.supportsPostUpdateNotification(
|
|
gatewayVersion: "2026.7.3",
|
|
appVersion: "2026.7.4"))
|
|
#expect(!PostUpdateController.supportsPostUpdateNotification(
|
|
gatewayVersion: "2026.7.4-beta.1",
|
|
appVersion: "2026.7.4"))
|
|
#expect(!PostUpdateController.supportsPostUpdateNotification(
|
|
gatewayVersion: nil,
|
|
appVersion: "2026.7.4"))
|
|
#expect(PostUpdateController.remoteNotificationBlocker(
|
|
gatewayVersion: nil,
|
|
appVersion: "2026.7.4") == .retryLater)
|
|
#expect(PostUpdateController.remoteNotificationBlocker(
|
|
gatewayVersion: "2026.7.3",
|
|
appVersion: "2026.7.4") == .skippedUnsupportedGateway)
|
|
#expect(PostUpdateController.remoteNotificationBlocker(
|
|
gatewayVersion: "2026.7.4",
|
|
appVersion: "2026.7.4") == nil)
|
|
}
|
|
|
|
@Test func `post-update runtime ownership follows the active service`() {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
let managedEntry = "\(home)/.openclaw/lib/node_modules/openclaw/dist/index.js"
|
|
|
|
#expect(PostUpdateController.ownsManagedRuntime(
|
|
connectionMode: .local,
|
|
programArguments: ["/usr/local/bin/node", managedEntry, "gateway"],
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "exact",
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!PostUpdateController.ownsManagedRuntime(
|
|
connectionMode: .local,
|
|
programArguments: ["/usr/local/bin/node", managedEntry, "gateway"],
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "exact",
|
|
launchAgentWriteDisabled: true))
|
|
#expect(PostUpdateController.ownsManagedRuntime(
|
|
connectionMode: .remote,
|
|
programArguments: ["/usr/local/bin/node", managedEntry, "node", "run"],
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "exact",
|
|
launchAgentWriteDisabled: true))
|
|
#expect(!PostUpdateController.ownsManagedRuntime(
|
|
connectionMode: .remote,
|
|
programArguments: ["/usr/local/bin/node", "/opt/openclaw/dist/index.js", "node", "run"],
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "exact",
|
|
launchAgentWriteDisabled: false))
|
|
}
|
|
|
|
@Test func `post-update window is reserved for managed Gateway work`() {
|
|
let managed = CLIInstaller.managedExecutableLocation()
|
|
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .ready(location: managed, version: "2026.7.4"),
|
|
ownsManagedRuntime: true,
|
|
gatewayUpdateIncomplete: false) == .none)
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .ready(location: managed, version: "2026.7.4"),
|
|
ownsManagedRuntime: true,
|
|
gatewayUpdateIncomplete: true) == .repair)
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .incompatible(location: managed, found: "2026.7.3", required: "2026.7.4"),
|
|
ownsManagedRuntime: true,
|
|
gatewayUpdateIncomplete: false) == .update)
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .missing(location: managed),
|
|
ownsManagedRuntime: true,
|
|
gatewayUpdateIncomplete: false) == .install)
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .incompatible(location: managed, found: "2026.7.5", required: "2026.7.4"),
|
|
ownsManagedRuntime: true,
|
|
gatewayUpdateIncomplete: false) == .none)
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .incompatible(location: managed, found: "2026.7.3", required: "2026.7.4"),
|
|
ownsManagedRuntime: false,
|
|
gatewayUpdateIncomplete: false) == .none)
|
|
#expect(PostUpdateController.gatewayAction(
|
|
status: .ready(location: managed, version: "2026.7.4"),
|
|
ownsManagedRuntime: false,
|
|
gatewayUpdateIncomplete: true) == .ownershipFailure)
|
|
}
|
|
|
|
@Test func `incomplete managed update keeps ownership failures retryable`() {
|
|
#expect(PostUpdateController.shouldPresentOwnershipFailure(
|
|
connectionMode: .local,
|
|
gatewayUpdateIncomplete: true))
|
|
#expect(PostUpdateController.shouldPresentOwnershipFailure(
|
|
connectionMode: .remote,
|
|
gatewayUpdateIncomplete: true))
|
|
#expect(!PostUpdateController.shouldPresentOwnershipFailure(
|
|
connectionMode: .local,
|
|
gatewayUpdateIncomplete: false))
|
|
#expect(!PostUpdateController.shouldPresentOwnershipFailure(
|
|
connectionMode: .remote,
|
|
gatewayUpdateIncomplete: false))
|
|
#expect(!PostUpdateController.shouldPresentOwnershipFailure(
|
|
connectionMode: .unconfigured,
|
|
gatewayUpdateIncomplete: true))
|
|
}
|
|
|
|
@Test func `notification retries only definitely uncommitted sends`() {
|
|
#expect(PostUpdateController.notificationSendFailureOutcome(
|
|
OpenClawChatTransportSendError.notDispatched) == .retryLater)
|
|
#expect(PostUpdateController.notificationSendFailureOutcome(
|
|
GatewayResponseError(
|
|
method: "system-event",
|
|
code: "INVALID_REQUEST",
|
|
message: "unsupported",
|
|
details: nil)) == .retryLater)
|
|
#expect(PostUpdateController.notificationSendFailureOutcome(
|
|
NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost)) == .deliveryUnconfirmed)
|
|
}
|
|
|
|
@Test func `Sparkle channels follow the Gateway update channel`() {
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "beta") == ["beta"])
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "dev") == ["beta"])
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel:
|
|
OpenClawConfigFile.normalizedGatewayUpdateChannel(" BETA \n")) == ["beta"])
|
|
#expect(OpenClawConfigFile.normalizedGatewayUpdateChannel(" \n") == nil)
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "stable").isEmpty)
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable") == ["extended-stable"])
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "future").isEmpty)
|
|
#expect(allowedSparkleChannels(forGatewayUpdateChannel: nil).isEmpty)
|
|
#expect(isSparkleUpdateAllowed(itemChannel: nil, forGatewayUpdateChannel: "stable"))
|
|
#expect(!isSparkleUpdateAllowed(itemChannel: nil, forGatewayUpdateChannel: "extended-stable"))
|
|
#expect(isSparkleUpdateAllowed(
|
|
itemChannel: "extended-stable",
|
|
forGatewayUpdateChannel: "extended-stable"))
|
|
}
|
|
|
|
#if canImport(Sparkle)
|
|
@Test func `Sparkle stays unavailable until launch relocation finishes`() {
|
|
let updater = SparkleUpdaterController(savedAutoUpdate: false)
|
|
|
|
#expect(!updater.isAvailable)
|
|
updater.checkForUpdates(nil)
|
|
#expect(!updater.isAvailable)
|
|
}
|
|
|
|
@Test func `Sparkle keeps legacy stable fallback when Gateway omits update channel`() async {
|
|
var starts = 0
|
|
let updater = SparkleUpdaterController(
|
|
savedAutoUpdate: false,
|
|
gatewayUpdateChannelResolver: { nil },
|
|
onStart: { starts += 1 })
|
|
|
|
updater.startAfterResolvingGatewayUpdateChannel()
|
|
for _ in 0..<10 where !updater.isAvailable {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(updater.isAvailable)
|
|
#expect(starts == 1)
|
|
}
|
|
|
|
@Test func `Sparkle keeps legacy stable fallback when Gateway channel lookup fails`() async {
|
|
var starts = 0
|
|
let updater = SparkleUpdaterController(
|
|
savedAutoUpdate: false,
|
|
gatewayUpdateChannelResolver: { throw CancellationError() },
|
|
onStart: { starts += 1 })
|
|
|
|
updater.startAfterResolvingGatewayUpdateChannel()
|
|
for _ in 0..<10 where !updater.isAvailable {
|
|
await Task.yield()
|
|
}
|
|
|
|
#expect(updater.isAvailable)
|
|
#expect(starts == 1)
|
|
}
|
|
#endif
|
|
|
|
@Test func `dashboard accepts only start update payloads`() {
|
|
#expect(DashboardWindowController.isStartUpdateRequest(["type": "start-update"]))
|
|
#expect(!DashboardWindowController.isStartUpdateRequest(["type": "update.run"]))
|
|
#expect(!DashboardWindowController.isStartUpdateRequest("start-update"))
|
|
}
|
|
|
|
@Test func `dashboard exposes update bridge only for available updater`() throws {
|
|
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
|
let auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
|
|
let available = TestUpdater(isAvailable: true)
|
|
let enabled = DashboardWindowController(url: url, auth: auth, updater: available)
|
|
let disabled = DashboardWindowController(
|
|
url: url,
|
|
auth: auth,
|
|
updater: TestUpdater(isAvailable: false))
|
|
let remote = DashboardWindowController(
|
|
url: url,
|
|
auth: auth,
|
|
updater: available,
|
|
updateBridgeEnabled: false)
|
|
|
|
#expect(enabled._testUpdateBridgeAvailable)
|
|
#expect(!disabled._testUpdateBridgeAvailable)
|
|
#expect(!remote._testUpdateBridgeAvailable)
|
|
remote.setUpdateBridgeEnabled(true)
|
|
#expect(remote._testUpdateBridgeAvailable)
|
|
}
|
|
|
|
@Test func `automatic repair is limited to incompatible managed install`() {
|
|
let managed = CLIInstaller.managedExecutableLocation()
|
|
#expect(CLIInstallPrompter.shouldAutomaticallyRepair(status: .incompatible(
|
|
location: managed,
|
|
found: "2026.7.1",
|
|
required: "2026.7.2"), launchAgentUsesManagedCLI: true, launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(status: .incompatible(
|
|
location: "/opt/homebrew/bin/openclaw",
|
|
found: "2026.7.1",
|
|
required: "2026.7.2"), launchAgentUsesManagedCLI: true, launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(
|
|
status: .missing(location: managed),
|
|
launchAgentUsesManagedCLI: true,
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(
|
|
status: .unusable(location: managed),
|
|
launchAgentUsesManagedCLI: true,
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(
|
|
status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"),
|
|
launchAgentUsesManagedCLI: false,
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(
|
|
status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"),
|
|
launchAgentUsesManagedCLI: true,
|
|
launchAgentWriteDisabled: true))
|
|
// Never silently downgrade a gateway the user moved ahead of the app.
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(status: .incompatible(
|
|
location: managed,
|
|
found: "2026.7.3",
|
|
required: "2026.7.2"), launchAgentUsesManagedCLI: true, launchAgentWriteDisabled: false))
|
|
// Extended-stable pins an older gateway on purpose; keep the prompt.
|
|
#expect(!CLIInstallPrompter.shouldAutomaticallyRepair(
|
|
status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"),
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: "extended-stable",
|
|
launchAgentWriteDisabled: false))
|
|
#expect(CLIInstallPrompter.shouldAutomaticallyRepair(
|
|
status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"),
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: "beta",
|
|
launchAgentWriteDisabled: false))
|
|
}
|
|
|
|
@Test func `CLI management follows configured node modes`() {
|
|
#expect(CLIInstallPrompter.shouldManageCLI(connectionMode: .local))
|
|
#expect(CLIInstallPrompter.shouldManageCLI(connectionMode: .remote))
|
|
#expect(!CLIInstallPrompter.shouldManageCLI(connectionMode: .unconfigured))
|
|
|
|
#expect(CLIInstallPrompter.shouldRestartManagedGateway(
|
|
requested: true,
|
|
connectionMode: .local))
|
|
#expect(!CLIInstallPrompter.shouldRestartManagedGateway(
|
|
requested: true,
|
|
connectionMode: .remote))
|
|
#expect(!CLIInstallPrompter.shouldRestartManagedGateway(
|
|
requested: false,
|
|
connectionMode: .local))
|
|
}
|
|
|
|
@Test func `managed repair only upgrades`() {
|
|
#expect(CLIInstallPrompter.isManagedUpgrade(found: "2026.7.1", required: "2026.7.2"))
|
|
#expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.1"))
|
|
#expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.2"))
|
|
// Prerelease of the same triple sorts below its release.
|
|
#expect(CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2-beta.1", required: "2026.7.2"))
|
|
#expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.2-beta.1"))
|
|
#expect(CLIInstallPrompter.isManagedUpgrade(
|
|
found: "2026.7.2-beta.1",
|
|
required: "2026.7.2-beta.2"))
|
|
#expect(CLIInstallPrompter.isManagedUpgrade(
|
|
found: "2026.7.2-beta.2",
|
|
required: "2026.7.2-beta.10"))
|
|
#expect(!CLIInstallPrompter.isManagedUpgrade(
|
|
found: "2026.7.2-beta.2",
|
|
required: "2026.7.2-beta.1"))
|
|
#expect(!CLIInstallPrompter.isManagedUpgrade(found: "garbage", required: "2026.7.2"))
|
|
}
|
|
|
|
@Test func `managed Gateway ownership ignores the generated environment wrapper`() {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
let wrapper = "\(home)/.openclaw/state/service-env/ai.openclaw.gateway-env-wrapper.sh"
|
|
let environment = "\(home)/.openclaw/state/service-env/ai.openclaw.gateway.env"
|
|
let managedEntry = "\(home)/.openclaw/lib/node_modules/openclaw/dist/index.js"
|
|
|
|
#expect(CLIInstallPrompter.launchAgentUsesManagedCLI(programArguments: [
|
|
wrapper,
|
|
environment,
|
|
"/usr/local/bin/node",
|
|
managedEntry,
|
|
"gateway",
|
|
]))
|
|
#expect(!CLIInstallPrompter.launchAgentUsesManagedCLI(programArguments: [
|
|
wrapper,
|
|
environment,
|
|
"/usr/local/bin/node",
|
|
"/opt/homebrew/lib/node_modules/openclaw/dist/index.js",
|
|
"gateway",
|
|
]))
|
|
#expect(!CLIInstallPrompter.launchAgentUsesManagedCLI(programArguments: [
|
|
wrapper,
|
|
environment,
|
|
"\(home)/.openclaw/tools/node/bin/node",
|
|
"/opt/homebrew/lib/node_modules/openclaw/dist/index.js",
|
|
"gateway",
|
|
]))
|
|
}
|
|
|
|
@Test func `managed repair gates cover bridge and repair alike`() {
|
|
#expect(CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: nil,
|
|
launchAgentWriteDisabled: false))
|
|
#expect(CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: "beta",
|
|
installPolicy: "exact",
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: false,
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: nil,
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: "extended-stable",
|
|
installPolicy: nil,
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: nil,
|
|
launchAgentWriteDisabled: true))
|
|
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "stable",
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "beta",
|
|
launchAgentWriteDisabled: false))
|
|
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
|
|
launchAgentUsesManagedCLI: true,
|
|
gatewayUpdateChannel: nil,
|
|
installPolicy: "dev",
|
|
launchAgentWriteDisabled: false))
|
|
}
|
|
|
|
@Test func `pending managed restart marker round trips`() {
|
|
CLIInstallPrompter.clearPendingManagedRestart()
|
|
#expect(!CLIInstallPrompter.hasPendingManagedRestart())
|
|
CLIInstallPrompter.setPendingManagedRestart()
|
|
#expect(CLIInstallPrompter.hasPendingManagedRestart())
|
|
CLIInstallPrompter.clearPendingManagedRestart()
|
|
#expect(!CLIInstallPrompter.hasPendingManagedRestart())
|
|
}
|
|
|
|
@Test func `managed Gateway restart requires a new running process`() {
|
|
#expect(CLIInstallPrompter.didManagedGatewayRestart(previousPID: nil, currentPID: 41))
|
|
#expect(CLIInstallPrompter.didManagedGatewayRestart(previousPID: 40, currentPID: 41))
|
|
#expect(!CLIInstallPrompter.didManagedGatewayRestart(previousPID: 41, currentPID: 41))
|
|
#expect(!CLIInstallPrompter.didManagedGatewayRestart(previousPID: 41, currentPID: nil))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class TestUpdater: UpdaterProviding {
|
|
var automaticallyChecksForUpdates = false
|
|
var automaticallyDownloadsUpdates = false
|
|
let isAvailable: Bool
|
|
let updateStatus = UpdateStatus()
|
|
|
|
init(isAvailable: Bool) {
|
|
self.isAvailable = isAvailable
|
|
}
|
|
|
|
func checkForUpdates(_: Any?) {}
|
|
}
|