diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 671edf9..c66ada2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: run: swift build -c release --product AstrolabeRuntime ios-simulator: - name: iOS Simulator tests + name: iOS Simulator and framework tests runs-on: macos-15 timeout-minutes: 30 @@ -56,5 +56,10 @@ jobs: - name: Show Xcode version run: xcodebuild -version - - name: Run iOS Simulator tests + - name: Run iOS Simulator and framework tests run: scripts/run-ios-simulator-tests.sh + + - name: Verify Release automatic runtime framework + run: | + ASTROLABE_ARTIFACT_CONFIGURATION=Release \ + scripts/verify-auto-start-artifact.sh diff --git a/Package.swift b/Package.swift index c1df1a2..629a8ae 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,11 @@ let package = Package( products: [ .library( name: "AstrolabeRuntime", - targets: ["AstrolabeRuntime"] + type: .dynamic, + targets: [ + "AstrolabeRuntime", + "AstrolabeRuntimeBootstrap" + ] ) ], dependencies: [ @@ -25,6 +29,11 @@ let package = Package( name: "AstrolabeRuntimeObjC", publicHeadersPath: "include" ), + .target( + name: "AstrolabeRuntimeBootstrap", + dependencies: ["AstrolabeRuntime"], + publicHeadersPath: "include" + ), .target( name: "AstrolabeRuntimeCore", dependencies: [ @@ -82,6 +91,7 @@ let package = Package( name: "AstrolabeRuntimeTests", dependencies: [ "AstrolabeRuntime", + "AstrolabeRuntimeCore", .product( name: "AstrolabeProtocol", package: "astrolabe-protocol" diff --git a/README.md b/README.md index e4d1984..1e002be 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ English | [简体中文](README.zh-CN.md) Astrolabe Runtime for iOS exposes UIKit and Core Animation inspection data to -the Astrolabe Host during development. Runtime activation is compiled out of -Release builds. +the Astrolabe Host during development. Linking the dynamic framework is the +complete integration: the Runtime starts automatically when an eligible +application process loads it. -Current package release: `2.0.0`. +Current package release: `2.1.0`. ## Requirements @@ -22,49 +23,24 @@ Add the following package in Xcode: https://github.com/regulusleow/astrolabe-runtime-ios ``` -Link the `AstrolabeRuntime` product to the application target. +Link and embed the `AstrolabeRuntime` product only in application targets that +are allowed to expose runtime inspection data, such as Debug and internal Beta +targets. Existing integrations upgraded from the former static product must +verify that the framework is configured as `Embed & Sign`. ## Integration -Start and stop the Runtime from the active scene lifecycle. +No source integration is required. Do not import the module or add +`AppDelegate` or `SceneDelegate` lifecycle calls. The dynamic framework +installs its process-level lifecycle observer when it is loaded, starts after +application launch, and keeps the same Runtime instance across foreground and +background transitions. -Objective-C: - -```objc -@import AstrolabeRuntime; - -- (void)sceneDidBecomeActive:(UIScene *)scene { -#if DEBUG - [ASTRuntime start]; -#endif -} - -- (void)sceneDidEnterBackground:(UIScene *)scene { -#if DEBUG - [ASTRuntime stop]; -#endif -} -``` - -Swift: - -```swift -func sceneDidBecomeActive(_ scene: UIScene) { -#if DEBUG - ASTRuntime.start() -#endif -} - -func sceneDidEnterBackground(_ scene: UIScene) { -#if DEBUG - ASTRuntime.stop() -#endif -} -``` - -Use `startWithCompletion:` when Objective-C code needs to handle startup -failures. `UIKitRuntimeLifecycle` is available for custom port selection, -request limits, or inspection authorization. +The Runtime does not inspect `DEBUG` or application configuration names. +Linking and embedding the product enables it in any build configuration. +Release and App Store targets must therefore remove `AstrolabeRuntime` from +their dependency graph and final application bundle. Treat a final artifact +scan as a required release gate. ## Capabilities @@ -84,10 +60,11 @@ discarded when the Runtime stops or the App process exits. | Target | Responsibility | | --- | --- | -| `AstrolabeRuntimeCore` | Protocol server, routing, sessions, transport, node registry, and platform-independent patch coordination used by the iOS SDK. | -| `AstrolabeRuntimeUIKit` | UIKit and Core Animation collection, attribute mapping, Auto Layout, accessibility, and allowlisted mutations. | -| `AstrolabeRuntime` | Public `ASTRuntime` facade, SDK metadata, lifecycle, and dependency composition. | -| `AstrolabeRuntimeObjC` | Minimal Objective-C Runtime metadata adapter. | +| `AstrolabeRuntimeCore` | Protocol server, routing, sessions, transport, node registry, and platform-independent patch coordination. | +| `AstrolabeRuntimeUIKit` | UIKit and Core Animation collection, attributes, layout, accessibility, and allowlisted mutations. | +| `AstrolabeRuntime` | Automatic installer, process lifecycle coordination, SDK metadata, and dependency composition. | +| `AstrolabeRuntimeBootstrap` | Objective-C dynamic-framework load hook with no public lifecycle API. | +| `AstrolabeRuntimeObjC` | Objective-C Runtime metadata adapter used by UIKit collection. | The package implements the platform-neutral Wire Protocol from `astrolabe-protocol`. iOS-specific collection, mapping, lifecycle, and port @@ -102,6 +79,7 @@ npm ci npm test swift test --parallel swift build -c release --product AstrolabeRuntime +scripts/verify-auto-start-artifact.sh ``` Run UIKit and integration tests in an iOS Simulator: @@ -115,9 +93,11 @@ xcodebuild \ ## Security -The Runtime listens only on a loopback TCP endpoint and is unavailable in -Release builds. USB transport also relies on host-device pairing enforced by -usbmux. Do not distribute Debug builds containing sensitive runtime data. +The Runtime listens only on a loopback TCP endpoint. USB transport also relies +on host-device pairing enforced by usbmux. The Runtime has no internal build +configuration gate: if its framework is present in a Release build, it will +start automatically. Do not distribute builds containing the Runtime or +sensitive runtime data. Temporary mutation is restricted to the Runtime-owned patch catalog. Arbitrary selectors, method invocation, and business actions are not exposed. diff --git a/README.zh-CN.md b/README.zh-CN.md index 72471b6..ca6c4c4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,9 +3,10 @@ [English](README.md) | 简体中文 Astrolabe Runtime for iOS 在开发期间向 Astrolabe Host 暴露 UIKit 和 Core Animation -检查数据。Release 构建不会编译 Runtime 激活逻辑。 +检查数据。链接 dynamic Framework 即完成全部接入;符合条件的 App 进程加载 Framework +后,Runtime 会自动启动。 -当前 Package 版本:`2.0.0`。 +当前 Package 版本:`2.1.0`。 ## 环境要求 @@ -21,48 +22,19 @@ Astrolabe Runtime for iOS 在开发期间向 Astrolabe Host 暴露 UIKit 和 Cor https://github.com/regulusleow/astrolabe-runtime-ios ``` -将 `AstrolabeRuntime` Product 链接到 App Target。 +只在允许暴露运行时检查数据的 App Target 中链接并嵌入 `AstrolabeRuntime` Product,例如 +Debug 和内部 Beta Target。从旧静态 Product 升级的既有工程需要确认 Framework 已配置为 +`Embed & Sign`。 ## 接入 -在当前 Scene 生命周期中启动和停止 Runtime。 +不需要添加任何业务代码。无需导入 Runtime module,也无需在 `AppDelegate` 或 +`SceneDelegate` 中调用生命周期 API。dynamic Framework 加载后会安装进程级生命周期观察者, +在 App 启动后开启 Runtime,并在前后台切换期间保持同一个 Runtime 实例。 -Objective-C: - -```objc -@import AstrolabeRuntime; - -- (void)sceneDidBecomeActive:(UIScene *)scene { -#if DEBUG - [ASTRuntime start]; -#endif -} - -- (void)sceneDidEnterBackground:(UIScene *)scene { -#if DEBUG - [ASTRuntime stop]; -#endif -} -``` - -Swift: - -```swift -func sceneDidBecomeActive(_ scene: UIScene) { -#if DEBUG - ASTRuntime.start() -#endif -} - -func sceneDidEnterBackground(_ scene: UIScene) { -#if DEBUG - ASTRuntime.stop() -#endif -} -``` - -Objective-C 代码需要处理启动失败时,可以使用 `startWithCompletion:`。需要自定义端口、请求限制或 -检查授权时,可以使用 `UIKitRuntimeLifecycle`。 +Runtime 不判断 `DEBUG`,也不识别业务工程的构建配置名称。只要链接并嵌入该 Product,任何构建配置 +都会启用 Runtime。因此 Release 和 App Store Target 必须从依赖图和最终 App Bundle 中彻底移除 +`AstrolabeRuntime`,并将最终制品扫描作为发布门禁。 ## 能力 @@ -81,10 +53,11 @@ Runtime 停止或 App 进程退出时失效。 | Target | 职责 | | --- | --- | -| `AstrolabeRuntimeCore` | iOS SDK 使用的协议服务、路由、Session、Transport、节点注册表和平台无关补丁协调。 | -| `AstrolabeRuntimeUIKit` | UIKit 和 Core Animation 采集、属性映射、Auto Layout、无障碍信息及白名单内的修改操作。 | -| `AstrolabeRuntime` | 公共 `ASTRuntime` 外观接口、SDK 元数据、生命周期和依赖组装。 | -| `AstrolabeRuntimeObjC` | 最小化的 Objective-C Runtime 元数据适配器。 | +| `AstrolabeRuntimeCore` | 协议服务、路由、Session、Transport、节点注册表和平台无关补丁协调。 | +| `AstrolabeRuntimeUIKit` | UIKit 和 Core Animation 采集、属性、布局、无障碍信息及白名单内的修改操作。 | +| `AstrolabeRuntime` | 自动安装器、进程生命周期协调、SDK 元数据和依赖组装。 | +| `AstrolabeRuntimeBootstrap` | 不暴露公共生命周期 API 的 Objective-C dynamic Framework 加载入口。 | +| `AstrolabeRuntimeObjC` | UIKit 采集使用的 Objective-C Runtime 元数据适配器。 | 该 Package 实现 `astrolabe-protocol` 定义的平台无关 Wire Protocol。iOS 特有的数据采集、映射、 生命周期和端口选择保留在本仓库中。 @@ -98,6 +71,7 @@ npm ci npm test swift test --parallel swift build -c release --product AstrolabeRuntime +scripts/verify-auto-start-artifact.sh ``` 在 iOS 模拟器中运行 UIKit 和集成测试: @@ -111,8 +85,9 @@ xcodebuild \ ## 安全性 -Runtime 仅监听 Loopback TCP Endpoint,并且在 Release 构建中不可用。USB Transport 同样依赖 -usbmux 强制执行的主机与设备配对机制。请勿分发包含敏感运行时数据的 Debug 构建。 +Runtime 仅监听 Loopback TCP Endpoint。USB Transport 同样依赖 usbmux 强制执行的主机与设备 +配对机制。Runtime 内部没有构建配置门禁;如果 Framework 进入 Release 构建,它也会自动启动。 +请勿分发包含 Runtime 或敏感运行时数据的构建。 临时修改仅限 Runtime 自身维护的补丁能力清单中声明的属性。Runtime 不会暴露任意 Selector、方法调用或业务操作。 diff --git a/Sources/AstrolabeRuntime/ASTRuntime.swift b/Sources/AstrolabeRuntime/ASTRuntime.swift deleted file mode 100644 index f7b3a2f..0000000 --- a/Sources/AstrolabeRuntime/ASTRuntime.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// ASTRuntime.swift -// astrolabe-runtime-ios -// -// Created by 轩辕十四 on 2026/7/11. -// - -#if canImport(UIKit) -import Foundation -import OSLog - -/// Provides the Objective-C entry point for controlling Astrolabe Runtime. -/// -/// Calls are serialized on the main actor and may be made from any thread. -@objc(ASTRuntime) -public final class ASTRuntime: NSObject { - private static let logger = Logger( - subsystem: "com.astrolabe.runtime", - category: "Lifecycle" - ) - - private override init() { - super.init() - } - - /// Starts the runtime using the SDK version advertised during handshake. - /// - /// Failures are logged. Use ``start(completion:)`` when the host - /// application needs to handle startup errors. - @objc public static func start() { - Task { @MainActor in - ASTRuntimeCoordinator.shared.start { error in - guard let error else { - return - } - logger.error( - "Astrolabe Runtime failed to start: \(error.localizedDescription, privacy: .public)" - ) - } - } - } - - /// Starts the runtime and reports the result on the main actor. - /// - /// Repeated calls complete successfully when the runtime is already - /// running. Calls made during a lifecycle transition report an error. - /// - /// - Parameter completion: Receives `nil` after startup or an error on - /// failure. - @objc(startWithCompletion:) - public static func start( - completion: (@MainActor @Sendable (NSError?) -> Void)? - ) { - Task { @MainActor in - ASTRuntimeCoordinator.shared.start(completion: completion) - } - } - - /// Stops the runtime and releases its listener and active connections. - @objc public static func stop() { - Task { @MainActor in - ASTRuntimeCoordinator.shared.stop(completion: nil) - } - } - - /// Stops the runtime and invokes the completion on the main actor. - /// - /// Repeated calls complete successfully when no runtime is active. - /// - /// - Parameter completion: Invoked after the runtime has stopped. - @objc(stopWithCompletion:) - public static func stop( - completion: (@MainActor @Sendable () -> Void)? - ) { - Task { @MainActor in - ASTRuntimeCoordinator.shared.stop(completion: completion) - } - } - - /// Reports whether the runtime is accepting inspection requests. - /// - /// - Parameter completion: Receives the current running state on the main - /// actor. - @objc(isRunningWithCompletion:) - public static func isRunning( - completion: @escaping @MainActor @Sendable (Bool) -> Void - ) { - Task { @MainActor in - completion(ASTRuntimeCoordinator.shared.isRunning) - } - } -} - -private enum ASTRuntimeFacadeState: Equatable { - case stopped - case starting - case running - case stopping -} - -@MainActor -private final class ASTRuntimeCoordinator { - static let shared = ASTRuntimeCoordinator() - - var isRunning: Bool { - state == .running - } - - private var state: ASTRuntimeFacadeState = .stopped - private var lifecycle: UIKitRuntimeLifecycle? - private var stopCompletions = [@MainActor @Sendable () -> Void]() - - private init() {} - - func start( - completion: (@MainActor @Sendable (NSError?) -> Void)? - ) { - switch state { - case .running: - completion?(nil) - return - case .starting, .stopping: - completion?( - UIKitRuntimeLifecycleError.invalidState as NSError - ) - return - case .stopped: - break - } - - do { - let lifecycle = try UIKitRuntimeLifecycle( - configuration: RuntimeServerConfiguration( - runtimeVersion: AstrolabeRuntimeSDK.runtimeVersion - ) - ) - self.lifecycle = lifecycle - state = .starting - Task { @MainActor [weak self] in - do { - _ = try await lifecycle.start() - guard self?.lifecycle === lifecycle, - self?.state == .starting else { - completion?( - UIKitRuntimeLifecycleError.invalidState as NSError - ) - return - } - self?.state = .running - completion?(nil) - } catch { - if self?.lifecycle === lifecycle { - let wasStopping = self?.state == .stopping - self?.lifecycle = nil - self?.state = .stopped - if wasStopping { - self?.completeStop() - } - } - completion?(error as NSError) - } - } - } catch { - state = .stopped - completion?(error as NSError) - } - } - - func stop(completion: (@MainActor @Sendable () -> Void)?) { - if let completion { - stopCompletions.append(completion) - } - - switch state { - case .stopped: - completeStop() - return - case .stopping: - return - case .starting, .running: - break - } - - guard let lifecycle else { - state = .stopped - completeStop() - return - } - state = .stopping - - Task { @MainActor [weak self] in - await lifecycle.stop() - guard self?.lifecycle === lifecycle else { - return - } - self?.lifecycle = nil - self?.state = .stopped - self?.completeStop() - } - } - - private func completeStop() { - let completions = stopCompletions - stopCompletions.removeAll() - completions.forEach { completion in - completion() - } - } -} -#endif diff --git a/Sources/AstrolabeRuntime/AstrolabeRuntime.swift b/Sources/AstrolabeRuntime/AstrolabeRuntime.swift index dac695f..ddd8e03 100644 --- a/Sources/AstrolabeRuntime/AstrolabeRuntime.swift +++ b/Sources/AstrolabeRuntime/AstrolabeRuntime.swift @@ -7,11 +7,11 @@ import AstrolabeProtocol -public enum AstrolabeRuntimeSDK { +package enum AstrolabeRuntimeSDK { /// Release version advertised by the embedded runtime. - public static let runtimeVersion = "2.0.0" + package static let runtimeVersion = "2.1.0" - public static var protocolVersion: RuntimeProtocolVersion { + package static var protocolVersion: RuntimeProtocolVersion { .v2 } } diff --git a/Sources/AstrolabeRuntime/AutomaticRuntimeBootstrapBridge.swift b/Sources/AstrolabeRuntime/AutomaticRuntimeBootstrapBridge.swift new file mode 100644 index 0000000..eb2cac8 --- /dev/null +++ b/Sources/AstrolabeRuntime/AutomaticRuntimeBootstrapBridge.swift @@ -0,0 +1,15 @@ +// +// AutomaticRuntimeBootstrapBridge.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +@_cdecl("AstrolabeRuntimeInstallAutomaticBootstrap") +package func installAutomaticAstrolabeRuntime() { + Task { @MainActor in + AutomaticRuntimeInstaller.shared.install() + } +} +#endif diff --git a/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift b/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift new file mode 100644 index 0000000..9038570 --- /dev/null +++ b/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift @@ -0,0 +1,65 @@ +// +// AutomaticRuntimeInstaller.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +import AstrolabeRuntimeCore +import OSLog + +@MainActor +package final class AutomaticRuntimeInstaller { + package static let shared = makeProduction() + + private let factsProvider: () -> RuntimeProcessFacts + private let eligibilityPolicy: RuntimeProcessEligibilityPolicy + private let coordinator: RuntimeProcessCoordinator + + package init( + factsProvider: @escaping () -> RuntimeProcessFacts, + eligibilityPolicy: RuntimeProcessEligibilityPolicy, + coordinator: RuntimeProcessCoordinator + ) { + self.factsProvider = factsProvider + self.eligibilityPolicy = eligibilityPolicy + self.coordinator = coordinator + } + + package func install() { + guard eligibilityPolicy.isEligible(factsProvider()) else { + return + } + coordinator.install() + } + + private static func makeProduction() -> AutomaticRuntimeInstaller { + let logger = Logger( + subsystem: "com.astrolabe.runtime", + category: "Lifecycle" + ) + let coordinator = RuntimeProcessCoordinator( + applicationLifecycleSource: + UIKitRuntimeApplicationLifecycleSource(), + lifecycleFactory: { + try UIKitRuntimeLifecycle( + configuration: RuntimeServerConfiguration( + runtimeVersion: AstrolabeRuntimeSDK.runtimeVersion + ) + ) + }, + failureReporter: { error in + logger.error( + "Astrolabe Runtime 启动失败:\(error.localizedDescription, privacy: .public)" + ) + } + ) + return AutomaticRuntimeInstaller( + factsProvider: { .current }, + eligibilityPolicy: RuntimeProcessEligibilityPolicy(), + coordinator: coordinator + ) + } +} +#endif diff --git a/Sources/AstrolabeRuntime/RuntimeApplicationLifecycleSource.swift b/Sources/AstrolabeRuntime/RuntimeApplicationLifecycleSource.swift new file mode 100644 index 0000000..0bf5827 --- /dev/null +++ b/Sources/AstrolabeRuntime/RuntimeApplicationLifecycleSource.swift @@ -0,0 +1,95 @@ +// +// RuntimeApplicationLifecycleSource.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +import Foundation +import UIKit + +@MainActor +package protocol RuntimeApplicationLifecycleSource: AnyObject { + var isApplicationActive: Bool { get } + + func installHandlers( + didFinishLaunching: @escaping @MainActor @Sendable () -> Void, + didBecomeActive: @escaping @MainActor @Sendable () -> Void + ) + + func invalidate() +} + +@MainActor +package final class UIKitRuntimeApplicationLifecycleSource: + RuntimeApplicationLifecycleSource +{ + package var isApplicationActive: Bool { + applicationIsActive() + } + + private let notificationCenter: NotificationCenter + private let didFinishLaunchingName: Notification.Name + private let didBecomeActiveName: Notification.Name + private let applicationIsActive: @MainActor () -> Bool + private var observerTokens = [NSObjectProtocol]() + + package init( + notificationCenter: NotificationCenter = .default, + didFinishLaunchingName: Notification.Name = + UIApplication.didFinishLaunchingNotification, + didBecomeActiveName: Notification.Name = + UIApplication.didBecomeActiveNotification, + applicationIsActive: @escaping @MainActor () -> Bool = { + UIApplication.shared.applicationState == .active + } + ) { + self.notificationCenter = notificationCenter + self.didFinishLaunchingName = didFinishLaunchingName + self.didBecomeActiveName = didBecomeActiveName + self.applicationIsActive = applicationIsActive + } + + deinit { + for token in observerTokens { + notificationCenter.removeObserver(token) + } + } + + package func installHandlers( + didFinishLaunching: @escaping @MainActor @Sendable () -> Void, + didBecomeActive: @escaping @MainActor @Sendable () -> Void + ) { + guard observerTokens.isEmpty else { + return + } + observerTokens = [ + observe(didFinishLaunchingName, handler: didFinishLaunching), + observe(didBecomeActiveName, handler: didBecomeActive) + ] + } + + package func invalidate() { + for token in observerTokens { + notificationCenter.removeObserver(token) + } + observerTokens.removeAll() + } + + private func observe( + _ name: Notification.Name, + handler: @escaping @MainActor @Sendable () -> Void + ) -> NSObjectProtocol { + notificationCenter.addObserver( + forName: name, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { + handler() + } + } + } +} +#endif diff --git a/Sources/AstrolabeRuntime/RuntimeConfiguration.swift b/Sources/AstrolabeRuntime/RuntimeConfiguration.swift deleted file mode 100644 index 731b314..0000000 --- a/Sources/AstrolabeRuntime/RuntimeConfiguration.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// RuntimeConfiguration.swift -// astrolabe-runtime-ios -// -// Created by 轩辕十四 on 2026/7/15. -// - -import AstrolabeRuntimeCore - -/// Configuration accepted by the public Runtime lifecycle. -public typealias RuntimeServerConfiguration = - AstrolabeRuntimeCore.RuntimeServerConfiguration - -/// Port-selection policy accepted by the public Runtime lifecycle. -public typealias LocalTCPRuntimePortSelection = - AstrolabeRuntimeCore.LocalTCPRuntimePortSelection - -/// Default simulator and device port ranges used by the iOS Runtime. -public typealias RuntimePortDefaults = - AstrolabeRuntimeCore.RuntimePortDefaults diff --git a/Sources/AstrolabeRuntime/RuntimeProcessCoordinator.swift b/Sources/AstrolabeRuntime/RuntimeProcessCoordinator.swift new file mode 100644 index 0000000..1aff547 --- /dev/null +++ b/Sources/AstrolabeRuntime/RuntimeProcessCoordinator.swift @@ -0,0 +1,158 @@ +// +// RuntimeProcessCoordinator.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +import AstrolabeRuntimeCore +import Foundation + +@MainActor +package protocol RuntimeProcessLifecycle: AnyObject { + func start() async throws -> RuntimeTransportEndpoint + func stop() async +} + +package enum RuntimeProcessState: Equatable { + case notInstalled + case waitingForApplication + case starting + case running + case failed(retryAvailable: Bool) +} + +@MainActor +package final class RuntimeProcessCoordinator { + package typealias LifecycleFactory = + @MainActor () throws -> any RuntimeProcessLifecycle + package typealias FailureReporter = + @MainActor @Sendable (Error) -> Void + + package private(set) var state: RuntimeProcessState = .notInstalled + + private let applicationLifecycleSource: + any RuntimeApplicationLifecycleSource + private let lifecycleFactory: LifecycleFactory + private let failureReporter: FailureReporter + private var runtimeLifecycle: (any RuntimeProcessLifecycle)? + private var startupTask: Task? + + package init( + applicationLifecycleSource: + any RuntimeApplicationLifecycleSource, + lifecycleFactory: @escaping LifecycleFactory, + failureReporter: @escaping FailureReporter + ) { + self.applicationLifecycleSource = applicationLifecycleSource + self.lifecycleFactory = lifecycleFactory + self.failureReporter = failureReporter + } + + package func install() { + guard state == .notInstalled else { + return + } + state = .waitingForApplication + applicationLifecycleSource.installHandlers( + didFinishLaunching: { [weak self] in + self?.startInitialAttemptIfNeeded() + }, + didBecomeActive: { [weak self] in + self?.handleDidBecomeActive() + } + ) + if applicationLifecycleSource.isApplicationActive { + startInitialAttemptIfNeeded() + } + } + + package func waitForStartupForTesting() async { + await startupTask?.value + } + + package func shutdownForTesting() async { + applicationLifecycleSource.invalidate() + let lifecycle = runtimeLifecycle + runtimeLifecycle = nil + startupTask?.cancel() + startupTask = nil + state = .notInstalled + await lifecycle?.stop() + } + + private func startInitialAttemptIfNeeded() { + guard state == .waitingForApplication else { + return + } + start(.initial) + } + + private func handleDidBecomeActive() { + switch state { + case .waitingForApplication: + start(.initial) + case .failed(retryAvailable: true): + start(.retry) + case .notInstalled, + .starting, + .running, + .failed(retryAvailable: false): + return + } + } + + private func start(_ attempt: StartupAttempt) { + let lifecycle: any RuntimeProcessLifecycle + do { + lifecycle = try lifecycleFactory() + } catch { + failureReporter(error) + state = .failed(retryAvailable: attempt == .initial) + return + } + + runtimeLifecycle = lifecycle + state = .starting + startupTask = Task { [weak self, lifecycle] in + let result: Result + do { + result = .success(try await lifecycle.start()) + } catch { + result = .failure(error) + } + self?.completeStart( + lifecycle: lifecycle, + attempt: attempt, + result: result + ) + } + } + + private func completeStart( + lifecycle: any RuntimeProcessLifecycle, + attempt: StartupAttempt, + result: Result + ) { + guard runtimeLifecycle === lifecycle, state == .starting else { + return + } + startupTask = nil + + switch result { + case .success: + state = .running + case let .failure(error): + runtimeLifecycle = nil + failureReporter(error) + state = .failed(retryAvailable: attempt == .initial) + } + } +} + +private enum StartupAttempt { + case initial + case retry +} +#endif diff --git a/Sources/AstrolabeRuntime/RuntimeProcessEligibilityPolicy.swift b/Sources/AstrolabeRuntime/RuntimeProcessEligibilityPolicy.swift new file mode 100644 index 0000000..8d0c85f --- /dev/null +++ b/Sources/AstrolabeRuntime/RuntimeProcessEligibilityPolicy.swift @@ -0,0 +1,63 @@ +// +// RuntimeProcessEligibilityPolicy.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +import Foundation + +package struct RuntimeProcessFacts { + /// Whether the main bundle is an iOS application bundle. + package var isApplicationBundle: Bool + + /// Whether the current executable belongs to an app extension. + package var isApplicationExtension: Bool + + /// Whether the process is hosting an XCTest session. + package var isRunningTests: Bool + + /// Whether Xcode launched the process for a SwiftUI preview. + package var isSwiftUIPreview: Bool + + /// Whether the UIKit application class exists in the process. + package var hasUIKitApplicationClass: Bool + + package static var current: RuntimeProcessFacts { + let bundle = Bundle.main + let environment = ProcessInfo.processInfo.environment + let testEnvironmentKeys = [ + "XCTestConfigurationFilePath", + "XCTestSessionIdentifier", + "XCTestBundlePath" + ] + + return RuntimeProcessFacts( + isApplicationBundle: + bundle.object(forInfoDictionaryKey: "CFBundlePackageType") + as? String == "APPL", + isApplicationExtension: + bundle.object(forInfoDictionaryKey: "NSExtension") != nil + || bundle.bundleURL.pathExtension == "appex", + isRunningTests: + testEnvironmentKeys.contains { environment[$0] != nil } + || NSClassFromString("XCTestCase") != nil, + isSwiftUIPreview: + environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1", + hasUIKitApplicationClass: + NSClassFromString("UIApplication") != nil + ) + } +} + +package struct RuntimeProcessEligibilityPolicy { + package func isEligible(_ facts: RuntimeProcessFacts) -> Bool { + facts.isApplicationBundle + && !facts.isApplicationExtension + && !facts.isRunningTests + && !facts.isSwiftUIPreview + && facts.hasUIKitApplicationClass + } +} +#endif diff --git a/Sources/AstrolabeRuntime/UIKitRuntimeLifecycle.swift b/Sources/AstrolabeRuntime/UIKitRuntimeLifecycle.swift index bc12b11..b3d4681 100644 --- a/Sources/AstrolabeRuntime/UIKitRuntimeLifecycle.swift +++ b/Sources/AstrolabeRuntime/UIKitRuntimeLifecycle.swift @@ -11,7 +11,7 @@ import AstrolabeRuntimeCore import AstrolabeRuntimeUIKit import UIKit -public enum UIKitRuntimeLifecycleState: Equatable, Sendable { +package enum UIKitRuntimeLifecycleState: Equatable, Sendable { /// Runtime has no active listener. case stopped @@ -25,10 +25,7 @@ public enum UIKitRuntimeLifecycleState: Equatable, Sendable { case stopping } -public enum UIKitRuntimeLifecycleError: Error, Equatable, Sendable { - /// Runtime activation is compiled out for the current build configuration. - case unavailableInCurrentBuild - +package enum UIKitRuntimeLifecycleError: Error, Equatable, Sendable { /// The App-specific inspection policy rejected activation. case inspectionNotAllowed @@ -37,19 +34,21 @@ public enum UIKitRuntimeLifecycleError: Error, Equatable, Sendable { /// Transport started without publishing its bound endpoint. case missingEndpoint - } @MainActor -public final class UIKitRuntimeLifecycle: Sendable { +package final class UIKitRuntimeLifecycle: + RuntimeProcessLifecycle, + Sendable +{ /// Current lifecycle state for diagnostics and integration coordination. - public private(set) var state: UIKitRuntimeLifecycleState = .stopped + package private(set) var state: UIKitRuntimeLifecycleState = .stopped private let transport: LocalTCPRuntimeTransport private let server: RuntimeServer private let inspectionAuthorization: @MainActor @Sendable () -> Bool - public init( + package init( configuration: RuntimeServerConfiguration, portSelection: LocalTCPRuntimePortSelection = .platformDefault, inspectionAuthorization: @escaping @MainActor @Sendable () -> Bool = { @@ -77,11 +76,10 @@ public final class UIKitRuntimeLifecycle: Sendable { } @discardableResult - public func start() async throws -> RuntimeTransportEndpoint { + package func start() async throws -> RuntimeTransportEndpoint { guard state == .stopped else { throw UIKitRuntimeLifecycleError.invalidState } - #if DEBUG guard inspectionAuthorization() else { throw UIKitRuntimeLifecycleError.inspectionNotAllowed } @@ -102,12 +100,9 @@ public final class UIKitRuntimeLifecycle: Sendable { state = .stopped throw error } - #else - throw UIKitRuntimeLifecycleError.unavailableInCurrentBuild - #endif } - public func stop() async { + package func stop() async { guard state != .stopped, state != .stopping else { return } diff --git a/Sources/AstrolabeRuntimeBootstrap/ASTRuntimeBootstrap.m b/Sources/AstrolabeRuntimeBootstrap/ASTRuntimeBootstrap.m new file mode 100644 index 0000000..5736c9e --- /dev/null +++ b/Sources/AstrolabeRuntimeBootstrap/ASTRuntimeBootstrap.m @@ -0,0 +1,24 @@ +// +// ASTRuntimeBootstrap.m +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#import +#import + +#if TARGET_OS_IOS +extern void AstrolabeRuntimeInstallAutomaticBootstrap(void); + +@interface ASTRuntimeBootstrap : NSObject +@end + +@implementation ASTRuntimeBootstrap + ++ (void)load { + AstrolabeRuntimeInstallAutomaticBootstrap(); +} + +@end +#endif diff --git a/Sources/AstrolabeRuntimeBootstrap/include/AstrolabeRuntimeBootstrap.h b/Sources/AstrolabeRuntimeBootstrap/include/AstrolabeRuntimeBootstrap.h new file mode 100644 index 0000000..8d90f8d --- /dev/null +++ b/Sources/AstrolabeRuntimeBootstrap/include/AstrolabeRuntimeBootstrap.h @@ -0,0 +1,8 @@ +// +// AstrolabeRuntimeBootstrap.h +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#import diff --git a/Sources/AstrolabeRuntimeCore/RuntimeServer.swift b/Sources/AstrolabeRuntimeCore/RuntimeServer.swift index 441220b..78abd39 100644 --- a/Sources/AstrolabeRuntimeCore/RuntimeServer.swift +++ b/Sources/AstrolabeRuntimeCore/RuntimeServer.swift @@ -63,9 +63,6 @@ package actor RuntimeServer { } package func start() async throws { - #if !DEBUG - throw RuntimeServerError.unavailableInCurrentBuild - #else guard acceptTask == nil else { throw RuntimeServerError.alreadyRunning } @@ -86,7 +83,6 @@ package actor RuntimeServer { await self?.transportStopped(runID: runID) } } - #endif } package func stop() async { @@ -277,7 +273,6 @@ package actor RuntimeServer { } package enum RuntimeServerError: Error, Equatable, Sendable { - case unavailableInCurrentBuild case alreadyRunning case invalidRequestTimeout case invalidMaximumConcurrentRequests diff --git a/Tests/AstrolabeRuntimeTests/ASTRuntimeTests.swift b/Tests/AstrolabeRuntimeTests/ASTRuntimeTests.swift deleted file mode 100644 index 625329a..0000000 --- a/Tests/AstrolabeRuntimeTests/ASTRuntimeTests.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// ASTRuntimeTests.swift -// astrolabe-runtime-ios -// -// Created by 轩辕十四 on 2026/7/11. -// - -#if canImport(UIKit) -@testable import AstrolabeRuntime -import XCTest - -@MainActor -final class ASTRuntimeTests: XCTestCase { - override func tearDown() async throws { - await stopRuntime() - try await super.tearDown() - } - - func testObjectiveCSelectorsAreExposed() { - XCTAssertTrue( - ASTRuntime.responds( - to: NSSelectorFromString("start") - ) - ) - XCTAssertTrue( - ASTRuntime.responds( - to: NSSelectorFromString("startWithCompletion:") - ) - ) - XCTAssertTrue(ASTRuntime.responds(to: NSSelectorFromString("stop"))) - XCTAssertTrue( - ASTRuntime.responds( - to: NSSelectorFromString("stopWithCompletion:") - ) - ) - XCTAssertTrue( - ASTRuntime.responds( - to: NSSelectorFromString("isRunningWithCompletion:") - ) - ) - } - - func testStartAndStopRuntime() async { - let startExpectation = expectation(description: "Runtime starts") - var startupError: NSError? - - ASTRuntime.start { error in - startupError = error - startExpectation.fulfill() - } - await fulfillment(of: [startExpectation], timeout: 2) - - XCTAssertNil(startupError) - var isRunning = await runtimeIsRunning() - XCTAssertTrue(isRunning) - - await stopRuntime() - isRunning = await runtimeIsRunning() - XCTAssertFalse(isRunning) - } - - func testDuplicateStartCompletesSuccessfully() async { - let firstStart = expectation(description: "First start completes") - ASTRuntime.start { error in - XCTAssertNil(error) - firstStart.fulfill() - } - await fulfillment(of: [firstStart], timeout: 2) - - let duplicateStart = expectation( - description: "Duplicate start completes" - ) - ASTRuntime.start { error in - XCTAssertNil(error) - duplicateStart.fulfill() - } - await fulfillment(of: [duplicateStart], timeout: 1) - - let isRunning = await runtimeIsRunning() - XCTAssertTrue(isRunning) - } - - func testStartDuringStartupReportsTransitionError() async { - let firstStart = expectation(description: "First start completes") - ASTRuntime.start { error in - XCTAssertNil(error) - firstStart.fulfill() - } - - let duplicateStart = expectation( - description: "Duplicate start reports an error" - ) - ASTRuntime.start { error in - XCTAssertNotNil(error) - duplicateStart.fulfill() - } - - await fulfillment( - of: [firstStart, duplicateStart], - timeout: 2 - ) - let isRunning = await runtimeIsRunning() - XCTAssertTrue(isRunning) - } - - func testRepeatedStopCompletesAfterRuntimeStops() async { - let startExpectation = expectation(description: "Runtime starts") - ASTRuntime.start { error in - XCTAssertNil(error) - startExpectation.fulfill() - } - await fulfillment(of: [startExpectation], timeout: 2) - - let firstStop = expectation(description: "First stop completes") - ASTRuntime.stop { - firstStop.fulfill() - } - let secondStop = expectation(description: "Second stop completes") - ASTRuntime.stop { - secondStop.fulfill() - } - - await fulfillment(of: [firstStop, secondStop], timeout: 2) - let isRunning = await runtimeIsRunning() - XCTAssertFalse(isRunning) - } - - func testRuntimeCanStartFromBackgroundThread() async { - let startExpectation = expectation( - description: "Background start completes" - ) - await Task.detached { - ASTRuntime.start { error in - XCTAssertNil(error) - startExpectation.fulfill() - } - }.value - - await fulfillment(of: [startExpectation], timeout: 2) - let isRunning = await runtimeIsRunning() - XCTAssertTrue(isRunning) - } - - private func stopRuntime() async { - await withCheckedContinuation { continuation in - ASTRuntime.stop { - continuation.resume() - } - } - } - - private func runtimeIsRunning() async -> Bool { - await withCheckedContinuation { continuation in - ASTRuntime.isRunning { isRunning in - continuation.resume(returning: isRunning) - } - } - } -} -#endif diff --git a/Tests/AstrolabeRuntimeTests/AutomaticRuntimeInstallerTests.swift b/Tests/AstrolabeRuntimeTests/AutomaticRuntimeInstallerTests.swift new file mode 100644 index 0000000..0d43cc2 --- /dev/null +++ b/Tests/AstrolabeRuntimeTests/AutomaticRuntimeInstallerTests.swift @@ -0,0 +1,70 @@ +// +// AutomaticRuntimeInstallerTests.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +@testable import AstrolabeRuntime +import XCTest + +@MainActor +final class AutomaticRuntimeInstallerTests: XCTestCase { + func testEligibleProcessInstallsCoordinator() { + let harness = makeHarness(eligible: true) + + harness.installer.install() + + XCTAssertEqual(harness.source.installCount, 1) + } + + func testIneligibleProcessDoesNotInstallCoordinator() { + let harness = makeHarness(eligible: false) + + harness.installer.install() + + XCTAssertEqual(harness.source.installCount, 0) + } + + func testRepeatedInstallDoesNotDuplicateObservers() { + let harness = makeHarness(eligible: true) + + harness.installer.install() + harness.installer.install() + + XCTAssertEqual(harness.source.installCount, 1) + } + + private func makeHarness(eligible: Bool) -> InstallerHarness { + let source = TestApplicationLifecycleSource() + let lifecycle = TestRuntimeProcessLifecycle() + let coordinator = RuntimeProcessCoordinator( + applicationLifecycleSource: source, + lifecycleFactory: { lifecycle }, + failureReporter: { _ in } + ) + let facts = RuntimeProcessFacts( + isApplicationBundle: eligible, + isApplicationExtension: false, + isRunningTests: false, + isSwiftUIPreview: false, + hasUIKitApplicationClass: true + ) + return InstallerHarness( + installer: AutomaticRuntimeInstaller( + factsProvider: { facts }, + eligibilityPolicy: RuntimeProcessEligibilityPolicy(), + coordinator: coordinator + ), + source: source + ) + } +} + +@MainActor +private struct InstallerHarness { + let installer: AutomaticRuntimeInstaller + let source: TestApplicationLifecycleSource +} +#endif diff --git a/Tests/AstrolabeRuntimeTests/RuntimeApplicationLifecycleSourceTests.swift b/Tests/AstrolabeRuntimeTests/RuntimeApplicationLifecycleSourceTests.swift new file mode 100644 index 0000000..659305f --- /dev/null +++ b/Tests/AstrolabeRuntimeTests/RuntimeApplicationLifecycleSourceTests.swift @@ -0,0 +1,59 @@ +// +// RuntimeApplicationLifecycleSourceTests.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +@testable import AstrolabeRuntime +import XCTest + +@MainActor +final class RuntimeApplicationLifecycleSourceTests: XCTestCase { + func testForwardsLaunchAndActivationNotifications() { + let center = NotificationCenter() + let launchNotification = Notification.Name("test.runtime.launch") + let activationNotification = Notification.Name("test.runtime.active") + let source = UIKitRuntimeApplicationLifecycleSource( + notificationCenter: center, + didFinishLaunchingName: launchNotification, + didBecomeActiveName: activationNotification, + applicationIsActive: { false } + ) + var launchCount = 0 + var activationCount = 0 + + source.installHandlers( + didFinishLaunching: { launchCount += 1 }, + didBecomeActive: { activationCount += 1 } + ) + center.post(name: launchNotification, object: nil) + center.post(name: activationNotification, object: nil) + + XCTAssertEqual(launchCount, 1) + XCTAssertEqual(activationCount, 1) + } + + func testInvalidateStopsNotificationDelivery() { + let center = NotificationCenter() + let launchNotification = Notification.Name("test.runtime.launch") + let source = UIKitRuntimeApplicationLifecycleSource( + notificationCenter: center, + didFinishLaunchingName: launchNotification, + didBecomeActiveName: .init("test.runtime.active"), + applicationIsActive: { false } + ) + var launchCount = 0 + source.installHandlers( + didFinishLaunching: { launchCount += 1 }, + didBecomeActive: {} + ) + + source.invalidate() + center.post(name: launchNotification, object: nil) + + XCTAssertEqual(launchCount, 0) + } +} +#endif diff --git a/Tests/AstrolabeRuntimeTests/RuntimeModuleBoundaryTests.swift b/Tests/AstrolabeRuntimeTests/RuntimeModuleBoundaryTests.swift index 087b5c7..4b1c6a1 100644 --- a/Tests/AstrolabeRuntimeTests/RuntimeModuleBoundaryTests.swift +++ b/Tests/AstrolabeRuntimeTests/RuntimeModuleBoundaryTests.swift @@ -21,7 +21,8 @@ final class RuntimeModuleBoundaryTests: XCTestCase { "AstrolabeRuntimeCore", "AstrolabeRuntimeUIKit", "AstrolabeRuntime", - "AstrolabeRuntimeObjC" + "AstrolabeRuntimeObjC", + "AstrolabeRuntimeBootstrap" ] { XCTAssertTrue( packageManifest.contains("name: \"\(targetName)\""), @@ -34,7 +35,7 @@ final class RuntimeModuleBoundaryTests: XCTestCase { .appendingPathComponent("Sources/AstrolabeRuntime/RuntimeServer.swift") .path ), - "RuntimeServer must not remain in the facade target" + "RuntimeServer must not remain in the composition target" ) XCTAssertFalse( FileManager.default.fileExists( @@ -42,11 +43,54 @@ final class RuntimeModuleBoundaryTests: XCTestCase { .appendingPathComponent("Sources/AstrolabeRuntime/UIKitHierarchyCollector.swift") .path ), - "UIKitHierarchyCollector must not remain in the facade target" + "UIKitHierarchyCollector must not remain in the composition target" + ) + XCTAssertTrue( + packageManifest.contains("type: .dynamic"), + "AstrolabeRuntime must be a dynamic library product" + ) + XCTAssertTrue( + packageManifest.contains("AstrolabeRuntimeBootstrap"), + "The dynamic product must include the bootstrap target" + ) + XCTAssertTrue( + packageManifest.contains( + "name: \"AstrolabeRuntimeBootstrap\",\n" + + " dependencies: [\"AstrolabeRuntime\"]" + ), + "The bootstrap target must link the Swift Runtime bridge" + ) + + let runtimeModuleDirectory = repositoryURL + .appendingPathComponent("Sources/AstrolabeRuntime") + let runtimeModuleSource = try FileManager.default + .contentsOfDirectory( + at: runtimeModuleDirectory, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "swift" } + .map { try String(contentsOf: $0, encoding: .utf8) } + .joined(separator: "\n") + XCTAssertFalse(runtimeModuleSource.contains("@objc(ASTRuntime)")) + XCTAssertFalse(runtimeModuleSource.contains("@_spi")) + XCTAssertFalse(runtimeModuleSource.contains("public static func start")) + XCTAssertFalse(runtimeModuleSource.contains("public static func stop")) + XCTAssertFalse( + runtimeModuleSource.contains("public static func isRunning") + ) + XCTAssertFalse( + runtimeModuleSource.contains( + "public typealias RuntimeServerConfiguration" + ) + ) + XCTAssertFalse( + runtimeModuleSource.contains( + "public typealias LocalTCPRuntimePortSelection" + ) ) } - func testRuntimeFacadeExposesCurrentReleaseVersion() throws { + func testRuntimeModuleUsesCurrentReleaseVersion() throws { let packageData = try Data( contentsOf: repositoryURL.appendingPathComponent("package.json") ) @@ -58,7 +102,7 @@ final class RuntimeModuleBoundaryTests: XCTestCase { XCTAssertEqual(AstrolabeRuntimeSDK.runtimeVersion, packageVersion) } - func testRuntimeFacadeExposesCurrentProtocolVersion() { + func testRuntimeModuleUsesCurrentProtocolVersion() { XCTAssertEqual(AstrolabeRuntimeSDK.protocolVersion, .v2) } diff --git a/Tests/AstrolabeRuntimeTests/RuntimeProcessCoordinatorTests.swift b/Tests/AstrolabeRuntimeTests/RuntimeProcessCoordinatorTests.swift new file mode 100644 index 0000000..2ad30ae --- /dev/null +++ b/Tests/AstrolabeRuntimeTests/RuntimeProcessCoordinatorTests.swift @@ -0,0 +1,246 @@ +// +// RuntimeProcessCoordinatorTests.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +@testable import AstrolabeRuntime +import XCTest + +@MainActor +final class RuntimeProcessCoordinatorTests: XCTestCase { + func testInstallIsIdempotent() { + let harness = makeHarness() + + harness.coordinator.install() + harness.coordinator.install() + + XCTAssertEqual(harness.source.installCount, 1) + XCTAssertEqual(harness.coordinator.state, .waitingForApplication) + } + + func testColdLaunchStartsAfterDidFinishLaunching() async { + let harness = makeHarness() + + harness.coordinator.install() + XCTAssertEqual(harness.lifecycle.startCount, 0) + + harness.source.sendDidFinishLaunching() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 1) + XCTAssertEqual(harness.coordinator.state, .running) + } + + func testAlreadyActiveApplicationStartsImmediately() async { + let harness = makeHarness(applicationIsActive: true) + + harness.coordinator.install() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 1) + XCTAssertEqual(harness.coordinator.state, .running) + } + + func testStartingAndRunningIgnoreDuplicateEvents() async { + let harness = makeHarness() + harness.coordinator.install() + + harness.source.sendDidFinishLaunching() + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 1) + XCTAssertEqual(harness.coordinator.state, .running) + } + + func testFailedInitialStartRetriesOnNextActivation() async { + let harness = makeHarness(startupErrors: [TestRuntimeFailure.startup]) + harness.coordinator.install() + + harness.source.sendDidFinishLaunching() + await harness.coordinator.waitForStartupForTesting() + XCTAssertEqual( + harness.coordinator.state, + .failed(retryAvailable: true) + ) + + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 2) + XCTAssertEqual(harness.coordinator.state, .running) + XCTAssertEqual(harness.reportedErrors.count, 1) + } + + func testFailedRetryDoesNotRetryAgain() async { + let harness = makeHarness( + startupErrors: [ + TestRuntimeFailure.startup, + TestRuntimeFailure.startup + ] + ) + harness.coordinator.install() + + harness.source.sendDidFinishLaunching() + await harness.coordinator.waitForStartupForTesting() + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 2) + XCTAssertEqual( + harness.coordinator.state, + .failed(retryAvailable: false) + ) + XCTAssertEqual(harness.reportedErrors.count, 2) + } + + func testInitialLifecycleFactoryFailureAllowsOneRetry() async { + let harness = makeHarness( + factoryErrors: [TestRuntimeFailure.startup] + ) + harness.coordinator.install() + + harness.source.sendDidFinishLaunching() + await harness.coordinator.waitForStartupForTesting() + XCTAssertEqual( + harness.coordinator.state, + .failed(retryAvailable: true) + ) + + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 1) + XCTAssertEqual(harness.coordinator.state, .running) + XCTAssertEqual(harness.reportedErrors.count, 1) + } + + func testRetryLifecycleFactoryFailureDisablesFurtherRetries() async { + let harness = makeHarness( + factoryErrors: [ + TestRuntimeFailure.startup, + TestRuntimeFailure.startup + ] + ) + harness.coordinator.install() + + harness.source.sendDidFinishLaunching() + await harness.coordinator.waitForStartupForTesting() + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + harness.source.sendDidBecomeActive() + await harness.coordinator.waitForStartupForTesting() + + XCTAssertEqual(harness.lifecycle.startCount, 0) + XCTAssertEqual( + harness.coordinator.state, + .failed(retryAvailable: false) + ) + XCTAssertEqual(harness.reportedErrors.count, 2) + } + + func testNoBackgroundEventStopsTheRuntime() async { + let harness = makeHarness(applicationIsActive: true) + harness.coordinator.install() + await harness.coordinator.waitForStartupForTesting() + + harness.source.isApplicationActive = false + + XCTAssertEqual(harness.lifecycle.stopCount, 0) + XCTAssertEqual(harness.coordinator.state, .running) + } + + func testShutdownForTestingInvalidatesSourceAndStopsLifecycle() async { + let harness = makeHarness(applicationIsActive: true) + harness.coordinator.install() + await harness.coordinator.waitForStartupForTesting() + + await harness.coordinator.shutdownForTesting() + + XCTAssertEqual(harness.source.invalidateCount, 1) + XCTAssertEqual(harness.lifecycle.stopCount, 1) + XCTAssertEqual(harness.coordinator.state, .notInstalled) + } + + func testShutdownDuringStartupPreventsStaleCompletion() async { + let harness = makeHarness() + harness.lifecycle.shouldSuspendStartup = true + harness.coordinator.install() + harness.source.sendDidFinishLaunching() + await harness.lifecycle.waitUntilStartupSuspends() + + XCTAssertEqual(harness.coordinator.state, .starting) + + await harness.coordinator.shutdownForTesting() + harness.lifecycle.resumeStartup() + await Task.yield() + + XCTAssertEqual(harness.lifecycle.stopCount, 1) + XCTAssertEqual(harness.coordinator.state, .notInstalled) + } + + private func makeHarness( + applicationIsActive: Bool = false, + startupErrors: [Error] = [], + factoryErrors: [TestRuntimeFailure] = [] + ) -> CoordinatorHarness { + let source = TestApplicationLifecycleSource() + source.isApplicationActive = applicationIsActive + let lifecycle = TestRuntimeProcessLifecycle() + lifecycle.startupErrors = startupErrors + let reportedErrors = ReportedErrors() + let factoryFailures = FactoryFailures(values: factoryErrors) + let coordinator = RuntimeProcessCoordinator( + applicationLifecycleSource: source, + lifecycleFactory: { + if !factoryFailures.values.isEmpty { + throw factoryFailures.values.removeFirst() + } + return lifecycle + }, + failureReporter: { error in + reportedErrors.values.append(error) + } + ) + return CoordinatorHarness( + coordinator: coordinator, + source: source, + lifecycle: lifecycle, + reportedErrors: reportedErrors + ) + } +} + +@MainActor +private final class ReportedErrors { + var values = [Error]() + + var count: Int { + values.count + } +} + +@MainActor +private final class FactoryFailures { + var values: [TestRuntimeFailure] + + init(values: [TestRuntimeFailure]) { + self.values = values + } +} + +@MainActor +private struct CoordinatorHarness { + let coordinator: RuntimeProcessCoordinator + let source: TestApplicationLifecycleSource + let lifecycle: TestRuntimeProcessLifecycle + let reportedErrors: ReportedErrors +} +#endif diff --git a/Tests/AstrolabeRuntimeTests/RuntimeProcessEligibilityPolicyTests.swift b/Tests/AstrolabeRuntimeTests/RuntimeProcessEligibilityPolicyTests.swift new file mode 100644 index 0000000..94df98d --- /dev/null +++ b/Tests/AstrolabeRuntimeTests/RuntimeProcessEligibilityPolicyTests.swift @@ -0,0 +1,64 @@ +// +// RuntimeProcessEligibilityPolicyTests.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +@testable import AstrolabeRuntime +import XCTest + +final class RuntimeProcessEligibilityPolicyTests: XCTestCase { + private let policy = RuntimeProcessEligibilityPolicy() + + func testAcceptsRegularUIKitApplication() { + XCTAssertTrue(policy.isEligible(regularApplication)) + } + + func testRejectsUnitTestProcess() { + var facts = regularApplication + facts.isRunningTests = true + + XCTAssertFalse(policy.isEligible(facts)) + } + + func testRejectsApplicationExtension() { + var facts = regularApplication + facts.isApplicationExtension = true + + XCTAssertFalse(policy.isEligible(facts)) + } + + func testRejectsSwiftUIPreview() { + var facts = regularApplication + facts.isSwiftUIPreview = true + + XCTAssertFalse(policy.isEligible(facts)) + } + + func testRejectsNonApplicationBundle() { + var facts = regularApplication + facts.isApplicationBundle = false + + XCTAssertFalse(policy.isEligible(facts)) + } + + func testRejectsProcessWithoutUIKitApplicationClass() { + var facts = regularApplication + facts.hasUIKitApplicationClass = false + + XCTAssertFalse(policy.isEligible(facts)) + } + + private var regularApplication: RuntimeProcessFacts { + RuntimeProcessFacts( + isApplicationBundle: true, + isApplicationExtension: false, + isRunningTests: false, + isSwiftUIPreview: false, + hasUIKitApplicationClass: true + ) + } +} +#endif diff --git a/Tests/AstrolabeRuntimeTests/RuntimeProcessTestDoubles.swift b/Tests/AstrolabeRuntimeTests/RuntimeProcessTestDoubles.swift new file mode 100644 index 0000000..0348d07 --- /dev/null +++ b/Tests/AstrolabeRuntimeTests/RuntimeProcessTestDoubles.swift @@ -0,0 +1,99 @@ +// +// RuntimeProcessTestDoubles.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/7/28. +// + +#if canImport(UIKit) +@testable import AstrolabeRuntime +import AstrolabeRuntimeCore + +@MainActor +final class TestApplicationLifecycleSource: + RuntimeApplicationLifecycleSource +{ + var isApplicationActive = false + private(set) var installCount = 0 + private(set) var invalidateCount = 0 + private var launchHandler: (@MainActor @Sendable () -> Void)? + private var activeHandler: (@MainActor @Sendable () -> Void)? + + func installHandlers( + didFinishLaunching: @escaping @MainActor @Sendable () -> Void, + didBecomeActive: @escaping @MainActor @Sendable () -> Void + ) { + installCount += 1 + launchHandler = didFinishLaunching + activeHandler = didBecomeActive + } + + func invalidate() { + invalidateCount += 1 + launchHandler = nil + activeHandler = nil + } + + func sendDidFinishLaunching() { + launchHandler?() + } + + func sendDidBecomeActive() { + activeHandler?() + } +} + +@MainActor +final class TestRuntimeProcessLifecycle: RuntimeProcessLifecycle { + private(set) var startCount = 0 + private(set) var stopCount = 0 + var startupErrors = [Error]() + var shouldSuspendStartup = false + + private var startupContinuation: + CheckedContinuation? + private var startupSuspensionWaiter: + CheckedContinuation? + private var isStartupSuspended = false + + func start() async throws -> RuntimeTransportEndpoint { + startCount += 1 + if shouldSuspendStartup { + isStartupSuspended = true + startupSuspensionWaiter?.resume() + startupSuspensionWaiter = nil + await withCheckedContinuation { continuation in + startupContinuation = continuation + } + } + if !startupErrors.isEmpty { + throw startupErrors.removeFirst() + } + return RuntimeTransportEndpoint(host: "127.0.0.1", port: 47_164) + } + + func stop() async { + stopCount += 1 + } + + func waitUntilStartupSuspends() async { + guard !isStartupSuspended else { + return + } + await withCheckedContinuation { continuation in + startupSuspensionWaiter = continuation + } + } + + func resumeStartup() { + shouldSuspendStartup = false + isStartupSuspended = false + startupContinuation?.resume() + startupContinuation = nil + } +} + +enum TestRuntimeFailure: Error { + case startup +} +#endif diff --git a/Tests/AstrolabeRuntimeTests/UIKitRuntimeLifecycleTests.swift b/Tests/AstrolabeRuntimeTests/UIKitRuntimeLifecycleTests.swift index 170f4f9..a4e85ee 100644 --- a/Tests/AstrolabeRuntimeTests/UIKitRuntimeLifecycleTests.swift +++ b/Tests/AstrolabeRuntimeTests/UIKitRuntimeLifecycleTests.swift @@ -8,15 +8,15 @@ #if canImport(UIKit) @testable import AstrolabeRuntime import AstrolabeProtocol +import AstrolabeRuntimeCore import XCTest @MainActor final class UIKitRuntimeLifecycleTests: XCTestCase { - #if DEBUG func testLifecycleStartsAndStopsDefaultRuntime() async throws { let lifecycle = try UIKitRuntimeLifecycle( configuration: RuntimeServerConfiguration( - runtimeVersion: "0.1.0" + runtimeVersion: "2.1.0" ), inspectionAuthorization: { true } ) @@ -35,7 +35,7 @@ final class UIKitRuntimeLifecycleTests: XCTestCase { func testLifecycleRejectsUnauthorizedInspection() async throws { let lifecycle = try UIKitRuntimeLifecycle( configuration: RuntimeServerConfiguration( - runtimeVersion: "0.1.0" + runtimeVersion: "2.1.0" ), portSelection: .ephemeral, inspectionAuthorization: { false } @@ -56,7 +56,7 @@ final class UIKitRuntimeLifecycleTests: XCTestCase { func testLifecycleRejectsDuplicateStart() async throws { let lifecycle = try UIKitRuntimeLifecycle( configuration: RuntimeServerConfiguration( - runtimeVersion: "0.1.0" + runtimeVersion: "2.1.0" ), portSelection: .ephemeral, inspectionAuthorization: { true } @@ -74,27 +74,18 @@ final class UIKitRuntimeLifecycleTests: XCTestCase { } await lifecycle.stop() } - #else - func testLifecycleDoesNotStartInRelease() async throws { - let lifecycle = try UIKitRuntimeLifecycle( + + func testLifecycleConformsToProcessLifecycle() throws { + let lifecycle: any RuntimeProcessLifecycle = + try UIKitRuntimeLifecycle( configuration: RuntimeServerConfiguration( - runtimeVersion: "0.1.0" + runtimeVersion: "2.1.0" ), portSelection: .ephemeral, inspectionAuthorization: { true } ) - do { - _ = try await lifecycle.start() - XCTFail("A release build must not start the Runtime") - } catch { - XCTAssertEqual( - error as? UIKitRuntimeLifecycleError, - .unavailableInCurrentBuild - ) - } - XCTAssertEqual(lifecycle.state, .stopped) + XCTAssertNotNil(lifecycle) } - #endif } #endif diff --git a/package-lock.json b/package-lock.json index 6bf2c0e..bb70bb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astrolabe-runtime-ios", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astrolabe-runtime-ios", - "version": "2.0.0" + "version": "2.1.0" } } } diff --git a/package.json b/package.json index e21aca4..6ea9bfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astrolabe-runtime-ios", - "version": "2.0.0", + "version": "2.1.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/release-prepare.mjs b/scripts/release-prepare.mjs index fbad57d..835e59e 100644 --- a/scripts/release-prepare.mjs +++ b/scripts/release-prepare.mjs @@ -28,7 +28,7 @@ export function prepareRelease({ commandRunner = makeCommandRunner(projectRoot) }) { assertReleaseVersion(version); - requireCleanSynchronizedBranch(commandRunner); + requireCleanSynchronizedBranch(commandRunner, version); requireUnpublishedTag(commandRunner, version); const previousVersion = canonicalRepositoryVersion(projectRoot); @@ -59,7 +59,7 @@ export function prepareRelease({ return { previousVersion, version, tag: version }; } -function requireCleanSynchronizedBranch(commandRunner) { +function requireCleanSynchronizedBranch(commandRunner, version) { if (runRequired(commandRunner, "git", ["status", "--porcelain"]).stdout.trim()) { throw new Error("Working tree is not clean; commit or remove existing changes first"); } @@ -67,16 +67,36 @@ function requireCleanSynchronizedBranch(commandRunner) { if (!branch) { throw new Error("Cannot prepare a release from a detached HEAD"); } + const expectedBranch = `release/${version}`; + if (branch !== expectedBranch) { + throw new Error(`Release preparation must run from ${expectedBranch}`); + } + const localHead = runRequired(commandRunner, "git", ["rev-parse", "HEAD"]).stdout.trim(); + const releaseHead = remoteBranchHead(commandRunner, branch); + if (releaseHead) { + if (localHead !== releaseHead) { + throw new Error(`Current HEAD does not match remote branch origin/${branch}`); + } + return; + } + + const developHead = remoteBranchHead(commandRunner, "develop"); + if (!developHead) { + throw new Error("Remote base branch origin/develop was not found"); + } + if (localHead !== developHead) { + throw new Error("A new release branch must start from current origin/develop"); + } +} + +function remoteBranchHead(commandRunner, branch) { const remoteLine = runRequired( commandRunner, "git", ["ls-remote", "--heads", "origin", `refs/heads/${branch}`] ).stdout.trim(); - const remoteHead = remoteLine.split(/\s+/)[0] ?? ""; - if (!remoteHead || localHead !== remoteHead) { - throw new Error(`Current HEAD does not match remote branch origin/${branch}`); - } + return remoteLine.split(/\s+/)[0] ?? ""; } function requireUnpublishedTag(commandRunner, version) { diff --git a/scripts/run-ios-simulator-tests.sh b/scripts/run-ios-simulator-tests.sh index d6f48d3..e02aa5c 100755 --- a/scripts/run-ios-simulator-tests.sh +++ b/scripts/run-ios-simulator-tests.sh @@ -2,6 +2,28 @@ set -euo pipefail +project_root="$( + cd "$(dirname "${BASH_SOURCE[0]}")/.." + pwd +)" +configuration="${ASTROLABE_TEST_CONFIGURATION:-Debug}" +case "$configuration" in + Debug|Release) + ;; + *) + printf 'Unsupported test configuration: %s\n' "$configuration" >&2 + exit 1 + ;; +esac + +test_arguments=( + -scheme astrolabe-runtime-ios + -configuration "$configuration" +) +if [[ "$configuration" == "Release" ]]; then + test_arguments+=(ENABLE_TESTABILITY=YES) +fi + select_runtime() { xcrun simctl list runtimes --json | python3 -c ' import json @@ -76,7 +98,11 @@ cleanup() { } trap cleanup EXIT -xcodebuild \ - -scheme astrolabe-runtime-ios \ - -destination "platform=iOS Simulator,id=${device_udid}" \ +test_arguments+=( + -destination "platform=iOS Simulator,id=${device_udid}" + "$@" test +) +xcodebuild "${test_arguments[@]}" +ASTROLABE_ARTIFACT_CONFIGURATION="$configuration" \ + "$project_root/scripts/verify-auto-start-artifact.sh" diff --git a/scripts/verify-auto-start-artifact.sh b/scripts/verify-auto-start-artifact.sh new file mode 100755 index 0000000..5d0288c --- /dev/null +++ b/scripts/verify-auto-start-artifact.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +configuration="${ASTROLABE_ARTIFACT_CONFIGURATION:-Debug}" +case "$configuration" in + Debug|Release) + ;; + *) + printf 'Unsupported artifact configuration: %s\n' "$configuration" >&2 + exit 1 + ;; +esac + +derived_data="$(mktemp -d "${TMPDIR:-/tmp}/astrolabe-runtime-artifact.XXXXXX")" +trap 'rm -rf "$derived_data"' EXIT + +xcodebuild \ + -scheme astrolabe-runtime-ios \ + -configuration "$configuration" \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$derived_data" \ + -quiet \ + build + +binary="$( + find "$derived_data/Build/Products" \ + -path '*/AstrolabeRuntime.framework/AstrolabeRuntime' \ + -type f \ + -print \ + -quit +)" + +if [[ -z "$binary" ]]; then + printf 'AstrolabeRuntime framework binary was not produced.\n' >&2 + exit 1 +fi + +file_output="$(file "$binary")" +if ! grep -q 'dynamically linked shared library' <<< "$file_output"; then + printf 'Framework is not a dynamic library: %s\n' "$file_output" >&2 + exit 1 +fi + +otool_output="$(otool -hv "$binary")" +if ! grep -Eq '(^|[[:space:]])DYLIB([[:space:]]|$)' <<< "$otool_output"; then + printf 'Framework Mach-O header is not MH_DYLIB.\n' >&2 + exit 1 +fi + +symbols="$(nm -gjU "$binary")" +if ! grep -Fqx '_OBJC_CLASS_$_ASTRuntimeBootstrap' <<< "$symbols"; then + printf 'Automatic Runtime Objective-C bootstrap symbol is missing.\n' >&2 + exit 1 +fi +if ! grep -Fqx '_AstrolabeRuntimeInstallAutomaticBootstrap' <<< "$symbols"; then + printf 'Automatic Runtime Swift bridge symbol is missing.\n' >&2 + exit 1 +fi +if grep -Fqx '_OBJC_CLASS_$_ASTRuntime' <<< "$symbols"; then + printf 'Legacy ASTRuntime class is still exported.\n' >&2 + exit 1 +fi + +printf 'Automatic Runtime framework artifact verified: %s\n' "$binary" diff --git a/test/ci-workflow.test.mjs b/test/ci-workflow.test.mjs index 8ed0663..54b96ab 100644 --- a/test/ci-workflow.test.mjs +++ b/test/ci-workflow.test.mjs @@ -3,15 +3,23 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; test("iOS Simulator CI provisions and targets an available runtime", async () => { - const [workflow, runner] = await Promise.all([ + const [workflow, runner, artifactVerifier] = await Promise.all([ readFile(".github/workflows/ci.yml", "utf8"), - readFile("scripts/run-ios-simulator-tests.sh", "utf8") + readFile("scripts/run-ios-simulator-tests.sh", "utf8"), + readFile("scripts/verify-auto-start-artifact.sh", "utf8") ]); assert.match(workflow, /scripts\/run-ios-simulator-tests\.sh/); + assert.match( + workflow, + /ASTROLABE_ARTIFACT_CONFIGURATION=Release/ + ); assert.doesNotMatch(workflow, /name=iPhone/); assert.match(runner, /xcodebuild -downloadPlatform iOS/); assert.match(runner, /xcrun simctl create/); assert.match(runner, /platform=iOS Simulator,id=\$\{device_udid\}/); assert.match(runner, /xcrun simctl delete/); + assert.match(runner, /verify-auto-start-artifact\.sh/); + assert.match(runner, /ASTROLABE_ARTIFACT_CONFIGURATION/); + assert.match(artifactVerifier, /-configuration "\$configuration"/); }); diff --git a/test/versioning.test.mjs b/test/versioning.test.mjs index 3d44d94..7181e3b 100644 --- a/test/versioning.test.mjs +++ b/test/versioning.test.mjs @@ -95,11 +95,14 @@ test("release preparation verifies, commits, and tags without pushing", () => { calls.push([command, ...args]); const key = [command, ...args].join(" "); if (key === "git branch --show-current") { - return { stdout: "develop\n", stderr: "", status: 0 }; + return { stdout: "release/2.0.0\n", stderr: "", status: 0 }; } if (key === "git rev-parse HEAD") { return { stdout: "abc123\n", stderr: "", status: 0 }; } + if (key === "git ls-remote --heads origin refs/heads/release/2.0.0") { + return { stdout: "", stderr: "", status: 0 }; + } if (key === "git ls-remote --heads origin refs/heads/develop") { return { stdout: "abc123\trefs/heads/develop\n", stderr: "", status: 0 }; } @@ -138,6 +141,145 @@ test("release preparation verifies, commits, and tags without pushing", () => { } }); +test("release preparation rejects a branch with an unexpected name", () => { + const fixture = makeVersionFixture(); + const commandRunner = releaseBranchCommandRunner({ + branch: "develop", + localHead: "abc123", + developHead: "abc123" + }); + + try { + assert.throws( + () => prepareRelease({ + projectRoot: fixture.root, + version: "2.0.0", + commandRunner + }), + /must run from release\/2\.0\.0/ + ); + } finally { + fixture.cleanup(); + } +}); + +test("release preparation rejects an out-of-date new release branch", () => { + const fixture = makeVersionFixture(); + const commandRunner = releaseBranchCommandRunner({ + branch: "release/2.0.0", + localHead: "abc123", + developHead: "def456" + }); + + try { + assert.throws( + () => prepareRelease({ + projectRoot: fixture.root, + version: "2.0.0", + commandRunner + }), + /must start from current origin\/develop/ + ); + } finally { + fixture.cleanup(); + } +}); + +test("release preparation rejects a diverged published release branch", () => { + const fixture = makeVersionFixture(); + const commandRunner = releaseBranchCommandRunner({ + branch: "release/2.0.0", + localHead: "abc123", + releaseHead: "def456", + developHead: "abc123" + }); + + try { + assert.throws( + () => prepareRelease({ + projectRoot: fixture.root, + version: "2.0.0", + commandRunner + }), + /does not match remote branch origin\/release\/2\.0\.0/ + ); + } finally { + fixture.cleanup(); + } +}); + +test("release preparation resumes a synchronized published release branch", () => { + const fixture = makeVersionFixture(); + const commandRunner = releaseBranchCommandRunner({ + branch: "release/2.0.0", + localHead: "abc123", + releaseHead: "abc123", + developHead: "def456", + changedPaths: fixture.expectedVersionedPaths + }); + + try { + assert.deepEqual( + prepareRelease({ + projectRoot: fixture.root, + version: "2.0.0", + commandRunner + }), + { + previousVersion: "0.1.3", + version: "2.0.0", + tag: "2.0.0" + } + ); + } finally { + fixture.cleanup(); + } +}); + +function releaseBranchCommandRunner({ + branch, + localHead, + releaseHead = "", + developHead, + changedPaths = [] +}) { + return (command, args) => { + const key = [command, ...args].join(" "); + if (key === "git status --porcelain") { + return { stdout: "", stderr: "", status: 0 }; + } + if (key === "git branch --show-current") { + return { stdout: `${branch}\n`, stderr: "", status: 0 }; + } + if (key === "git rev-parse HEAD") { + return { stdout: `${localHead}\n`, stderr: "", status: 0 }; + } + if (key === `git ls-remote --heads origin refs/heads/${branch}`) { + const stdout = releaseHead + ? `${releaseHead}\trefs/heads/${branch}\n` + : ""; + return { stdout, stderr: "", status: 0 }; + } + if (key === "git ls-remote --heads origin refs/heads/develop") { + return { + stdout: `${developHead}\trefs/heads/develop\n`, + stderr: "", + status: 0 + }; + } + if (key === "git diff HEAD --name-only") { + return { + stdout: changedPaths.length > 0 + ? `${changedPaths.join("\n")}\n` + : "", + stderr: "", + status: 0 + }; + } + return { stdout: "", stderr: "", status: 0 }; + }; +} + function makeVersionFixture() { const root = mkdtempSync(join(tmpdir(), "astrolabe-runtime-version-test-")); const swiftPath = "Sources/AstrolabeRuntime/AstrolabeRuntime.swift";