refactor(tests): drop TEST_HOST by linking tests against app-source static lib

单测不再以 app 可执行为宿主:新增 ShadowsocksX-NG2Core 静态库 target 同源
编译 App/(除 @main)与 Domain/,模块名沿用 ShadowsocksX_NG2 使全部测试的
@testable import 零改动;app target 模块改名 ShadowsocksX_NG2App 避免共享
Products 目录 swiftmodule 撞名。两个测试 target 改链 Core 后 xcodegen 不再
生成 TEST_HOST,xcodebuild test 只构建不启动 app——与安装版运行实例零接触
(pgrep 全程验证无 app 进程)。同源双编译沿用 Agent/Helper 共享 Domain 文件
的既定模式,Core 只被测试 target 依赖,发布链不编译它。

随之删除 MainApp.swift 的整套测试宿主脚手架(isUnitTesting/makeTesting/
Noop 服务/Ephemeral 存储,-142 行),app 在测试下不再启动,守卫失去存在
理由。宿主移除暴露三处 Bundle.main 依赖,改为显式缝:控制器新增 appBundle
注入(内置规则快照 BuiltinRuleCatalog 与 LaunchDaemon 清单指纹
launchDaemonPlistData(in:) 均按 bundle 读取,生产默认 .main 不变);XPC
listener delegate 期望客户端身份改为可注入(测试按本进程 SecCode 自查)。
测试经 AppArtifact.bundle(沿测试包落盘位置向上查找 app 产物,兼容
TEST_HOST 内嵌与平级两种布局)统一取产物。

665 单测 + 11 冒烟全绿,task build 生产构建不受影响。
This commit is contained in:
Qiu Yuzhou
2026-09-30 20:44:13 +08:00
parent ac60d14099
commit 7d0ebc79b4
14 changed files with 118 additions and 142 deletions
+3 -111
View File
@@ -63,11 +63,10 @@ struct ShadowsocksXNG2App: App {
}
}
/// 随窗激活策略的真实 NSApp 落点(hermetic:单测 host 不触碰激活策略)。
/// 随窗激活策略的真实 NSApp 落点。
@MainActor
private final class NSAppWindowActivationApplier: WindowActivationPolicyApplying {
func apply(_ policy: NSApplication.ActivationPolicy) {
guard !ApplicationDependencies.isUnitTesting else { return }
NSApp.setActivationPolicy(policy)
}
}
@@ -83,7 +82,6 @@ private struct StatusMenuLabel: View {
Image(systemName: "network")
.accessibilityLabel("ShadowsocksX-NG2")
.task {
guard !ApplicationDependencies.isUnitTesting else { return }
await control.resyncOnLaunch()
}
}
@@ -196,10 +194,8 @@ private struct AppComposition {
}
}
/// The test host still loads the app executable because this target is a macOS
/// SwiftUI application. Its bootstrap must nevertheless be hermetic: starting
/// XCTest must not read the user's catalog, settings, activation state, Legacy
/// defaults, login-item registration, runtime files, or production Keychain.
/// 组合根依赖集(仅生产形态):单测不再以本 app 为宿主(去宿主化,ADR 0021),
/// 装配永远面向真实存储、系统服务与用户默认域。
@MainActor
private struct ApplicationDependencies {
let credentials: CredentialStoring
@@ -217,18 +213,7 @@ private struct ApplicationDependencies {
let diagnosticReportExporter: any DiagnosticReportExporter
let configurationGroupFileExporter: any ConfigurationGroupFileExporter
static var isUnitTesting: Bool {
let environment = ProcessInfo.processInfo.environment
return environment["XCTestConfigurationFilePath"] != nil
|| environment["XCTestSessionIdentifier"] != nil
}
static func make() -> ApplicationDependencies {
guard isUnitTesting else { return makeProduction() }
return makeTesting()
}
private static func makeProduction() -> ApplicationDependencies {
// ADR 0017 初版文件形态的迁移(发布链从未包含,仅存量开发机):文件不存在
// 即空操作,随每次启动调用无害。
SilentLaunchStore.migrateLegacyFileIfPresent(
@@ -257,97 +242,4 @@ private struct ApplicationDependencies {
configurationGroupFileExporter: AppKitConfigurationGroupFileExporter())
}
private static func makeTesting() -> ApplicationDependencies {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ShadowsocksX-NG2-test-host-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
// 测试宿主不得触碰生产 defaults 域:走独立套件并在装配时清空(封闭性公约)。
let silentLaunchSuite = "ShadowsocksX-NG2-test-host-silent-launch"
UserDefaults.standard.removePersistentDomain(forName: silentLaunchSuite)
let silentLaunchStore = SilentLaunchStore(
defaults: UserDefaults(suiteName: silentLaunchSuite)!)
let credentials = EphemeralCredentialStore()
let catalogFileStore = CatalogFileStore(
fileURL: directory.appendingPathComponent("catalog.json"))
let settingsStore = ProxySettingsFileStore(
fileURL: directory.appendingPathComponent("settings.json"),
legacyListenFileURL: directory.appendingPathComponent("listen-settings.json"))
let restoredSettings = ProxySettingsFileStore.restored(store: settingsStore)
let marker = EphemeralLegacyImportMarkerStore()
let legacyImportService = LegacyImportService(
source: EmptyLegacySnapshotProvider(),
catalogStore: catalogFileStore,
credentials: credentials,
marker: marker)
return ApplicationDependencies(
credentials: credentials,
catalogFileStore: catalogFileStore,
activationFileStore: ActivationStateFileStore(
fileURL: directory.appendingPathComponent("activation.json")),
runtimeFileStore: RuntimeFileStore(
fileURL: directory.appendingPathComponent("sslocal-active.json")),
settingsStore: settingsStore,
settingsRestore: restoredSettings,
listenRestore: RestoredListenSettings(
settings: restoredSettings.settings.listen, unreadableError: nil),
legacyImportService: legacyImportService,
launchAgent: NoopLaunchAgentService(),
loginService: NoopLaunchAtLoginService(),
silentLaunchStore: silentLaunchStore,
textClipboard: InMemoryTextClipboard(),
diagnosticReportExporter: InMemoryDiagnosticReportExporter(),
configurationGroupFileExporter: InMemoryConfigurationGroupFileExporter())
}
}
private final class EphemeralCredentialStore: CredentialStoring {
private var values: [CredentialReference: String] = [:]
private let lock = NSLock()
func save(_ secret: String, for reference: CredentialReference) throws {
lock.lock()
defer { lock.unlock() }
values[reference] = secret
}
func secret(for reference: CredentialReference) throws -> String? {
lock.lock()
defer { lock.unlock() }
return values[reference]
}
func delete(_ reference: CredentialReference) throws {
lock.lock()
defer { lock.unlock() }
values.removeValue(forKey: reference)
}
}
private struct EmptyLegacySnapshotProvider: LegacySnapshotProviding {
func readSnapshot() throws -> LegacySnapshot? { nil }
}
private final class EphemeralLegacyImportMarkerStore: LegacyImportMarkerStoring {
private var completed = false
func isCompleted() throws -> Bool { completed }
func setCompleted(_ completed: Bool) throws {
self.completed = completed
}
}
private struct NoopLaunchAgentService: LaunchAgentControlling {
var status: LaunchAgentStatus { .notRegistered }
func register() throws {}
func unregister() throws {}
}
private struct NoopLaunchAtLoginService: LaunchAtLoginControlling {
var status: LoginItemStatus { .notRegistered }
func register() throws {}
func unregister() throws {}
}
@@ -133,15 +133,16 @@ extension ProxyRuntimeController {
/// (issue #63/#64)。快照缺失/损坏/版本不匹配时抛错,调用方必须失败
/// 并保留旧 ACL,不得静默退化成全局(issue #63 AC4)。
func chinaDirectRules() throws -> [ProxyRule] {
let geolocation = try BuiltinRuleCatalog.loadGeolocationCN()
let chinaIPv4 = try BuiltinRuleCatalog.loadChinaIPv4()
let geolocation = try BuiltinRuleCatalog.loadGeolocationCN(from: appBundle)
let chinaIPv4 = try BuiltinRuleCatalog.loadChinaIPv4(from: appBundle)
return BuiltinRuleCatalog.chinaDirectRules(from: [geolocation, chinaIPv4])
}
/// GFWList 候选(issue #65):可准确表达且未被更宽代理规则遮蔽的规则
/// (含未遮蔽例外),参与 `bypass_all` ACL 编译。
func gfwlistRules() throws -> [ProxyRule] {
BuiltinRuleCatalog.gfwlistRules(from: try BuiltinRuleCatalog.loadGFWList())
BuiltinRuleCatalog.gfwlistRules(
from: try BuiltinRuleCatalog.loadGFWList(from: appBundle))
}
private func deployModeTransition(
@@ -149,7 +149,7 @@ extension ProxyRuntimeController {
private func ensureHelperAvailableForApply() async -> Bool {
switch systemProxyHelper.status {
case .approved:
if Self.helperRegistrationDrifted() {
if helperRegistrationDrifted() {
do {
try systemProxyHelper.unregister()
// launchd 对节流中(spawn scheduled)job 的移除是异步的:立即重注
@@ -157,7 +157,7 @@ extension ProxyRuntimeController {
// runtime(10s)节流窗再重注;间隔内收敛以旧定义失败呈超时。
try? await Task.sleep(nanoseconds: helperRefreshDelayNanoseconds)
try systemProxyHelper.register()
Self.storeHelperRegistrationStamp()
storeHelperRegistrationStamp()
} catch {
// 刷新失败不记指纹,下次启动重试;状态退回后由下方分支接手。
RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error)))
@@ -170,7 +170,7 @@ extension ProxyRuntimeController {
case .notRegistered:
do {
try systemProxyHelper.register()
Self.storeHelperRegistrationStamp()
storeHelperRegistrationStamp()
} catch {
RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error)))
}
@@ -185,14 +185,16 @@ extension ProxyRuntimeController {
return false
}
private static func helperRegistrationDrifted(defaults: UserDefaults = .standard) -> Bool {
private func helperRegistrationDrifted(defaults: UserDefaults = .standard) -> Bool {
SystemProxyHelperRegistrationStamp.drifted(
plistData: SystemProxyHelperIdentity.launchDaemonPlistData,
plistData: SystemProxyHelperIdentity.launchDaemonPlistData(in: appBundle),
storedStamp: defaults.string(forKey: SystemProxyHelperRegistrationStamp.defaultsKey))
}
private static func storeHelperRegistrationStamp(defaults: UserDefaults = .standard) {
guard let plistData = SystemProxyHelperIdentity.launchDaemonPlistData else { return }
private func storeHelperRegistrationStamp(defaults: UserDefaults = .standard) {
guard let plistData = SystemProxyHelperIdentity.launchDaemonPlistData(in: appBundle) else {
return
}
defaults.set(
SystemProxyHelperRegistrationStamp.stamp(plistData),
forKey: SystemProxyHelperRegistrationStamp.defaultsKey)
@@ -94,6 +94,10 @@ final class ProxyRuntimeController: ObservableObject {
let systemProxyNetworkChangeMonitor: SystemProxyNetworkChangeMonitoring
let firewallChecker: FirewallStatusChecking
let firewallExecutableURLs: [URL]
/// 宿主 app bundle 缝(生产为 Bundle.main):内置规则快照与 LaunchDaemon
/// 清单等打包资源的定位基准。去宿主化(ADR 0021)后单测运行于 xctest
/// runner,`Bundle.main` 不再是 app,经此缝注入构建产物 bundle。
let appBundle: Bundle
let firewallPollIntervalNanoseconds: UInt64
let launchHealthTimeoutSeconds: TimeInterval
/// 注册清单漂移重注时,注销与重注的间隔(launchd 对节流中 job 的移除异步)。
@@ -126,6 +130,7 @@ final class ProxyRuntimeController: ObservableObject {
listenRestore: RestoredListenSettings = ListenSettingsFileStore.restored(),
settingsStore: ProxySettingsStoring = ProxySettingsFileStore(),
customRuleStore: CustomRuleStore = CustomRuleStore(),
appBundle: Bundle = .main,
settingsRestore: RestoredProxySettings? = nil,
agent: LaunchAgentControlling = SMAppLaunchAgentService(),
probe: EndpointProbing = SystemEndpointProbe(),
@@ -149,6 +154,7 @@ final class ProxyRuntimeController: ObservableObject {
self.plugins = plugins
self.settingsStore = settingsStore
self.customRuleStore = customRuleStore
self.appBundle = appBundle
let restoredSettings =
settingsRestore
?? RestoredProxySettings(
@@ -20,9 +20,11 @@ enum SystemProxyHelperIdentity {
/// App bundle 内 LaunchDaemon 清单内容(注册漂移指纹的基准)。helper 进程
/// 内没有 bundle,返回 nil,调用方按「无基准不动作」处理。Contents/Library
/// 不是资源目录,Bundle 资源 API 搜不到,按固定路径直读。
static var launchDaemonPlistData: Data? {
let url = Bundle.main.bundleURL
/// 不是资源目录,Bundle 资源 API 搜不到,按固定路径直读。bundle 参数生产
/// 默认 Bundle.main;去宿主化(ADR 0021)后单测经控制器的 appBundle 缝注入
/// 构建产物 bundle。
static func launchDaemonPlistData(in bundle: Bundle = .main) -> Data? {
let url = bundle.bundleURL
.appendingPathComponent("Contents/Library/LaunchDaemons")
.appendingPathComponent(plistName)
return try? Data(contentsOf: url)
@@ -6,11 +6,18 @@ import Security
/// 不校验 Team ID 或 UID。校验基于连接对端 pid 的 SecCode 查询。
final class SystemProxyHelperListenerDelegate: NSObject, NSXPCListenerDelegate {
private let engine: SystemProxyHelperEngine
private let expectedClientIdentifier: String
/// 引擎由进程入口显式注入:特权写入器 SystemProxyWriter 属 helper 专属
/// (不进共享编译单元),测试注入假写入器。
init(engine: SystemProxyHelperEngine) {
/// (不进共享编译单元),测试注入假写入器。期望客户端身份生产默认为 2.0
/// bundle identifier;去宿主化(ADR 0021)后单测运行于 xctest runner,
/// 连接方身份随宿主改变,测试注入本进程实际签名 identifier。
init(
engine: SystemProxyHelperEngine,
expectedClientIdentifier: String = SystemProxyHelperIdentity.clientCodeSigningIdentifier
) {
self.engine = engine
self.expectedClientIdentifier = expectedClientIdentifier
super.init()
}
@@ -20,7 +27,7 @@ final class SystemProxyHelperListenerDelegate: NSObject, NSXPCListenerDelegate {
) -> Bool {
guard
SystemProxyClientValidator.clientSigningIdentifier(newConnection)
== SystemProxyHelperIdentity.clientCodeSigningIdentifier
== expectedClientIdentifier
else { return false }
newConnection.exportedInterface = NSXPCInterface(with: SystemProxyHelperControlling.self)
newConnection.exportedObject = engine
+20 -8
View File
@@ -1,23 +1,35 @@
import Foundation
/// 构建产物定位:沿测试 bundle 落盘位置向上查找 app 产物。覆盖两种布局——
/// TEST_HOST 过渡期 `.xctest` 嵌在 app bundle 的 `Contents/PlugIns/` 内,
/// 去宿主化后 `.xctest` 与 `.app` 平级同在 `Build/Products/<config>/`。
/// 测试需要 app 产物(嵌入二进制、Info.plist 打包面断言)时一律经此定位,
/// 不得用 `Bundle.main`(去宿主化后它是 xctest runner 的 bundle)。
/// 构建产物定位:从测试 bundle 落盘位置向上逐层查找 app 产物。覆盖两种布局
/// ——TEST_HOST 过渡期 `.xctest` 嵌于 app bundle 的 `Contents/PlugIns/`(app
/// 是祖先),去宿主化后 `.xctest` 与 `.app` 平级同在 `Build/Products/<config>/`
/// (app 是某层祖先的子项)。测试需要 app 产物(嵌入二进制、Info.plist 打包面
/// 断言)时一律经此定位,不得用 `Bundle.main`(去宿主化后它是 xctest runner
/// 的 bundle)。
enum AppArtifact {
static let bundleURL: URL = {
var directory = Bundle(for: Marker.self).bundleURL
let testBundleURL = Bundle(for: Marker.self).bundleURL
var directory = testBundleURL
while directory.path != "/" {
directory.deleteLastPathComponent()
if directory.lastPathComponent == "ShadowsocksX-NG2.app" {
return directory
}
directory.deleteLastPathComponent()
let candidate = directory.appendingPathComponent(
"ShadowsocksX-NG2.app", isDirectory: true)
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
}
// 定位失败时返回原目录让用例以「产物缺失」失败并暴露实际路径,不炸进程。
return Bundle(for: Marker.self).bundleURL
return testBundleURL
}()
/// `bundleURL` 的 Bundle 形态:注入按 Bundle 取资源的生产缝(如
/// ProxyRuntimeController 的内置规则快照 bundle)。定位失败时退回测试
/// bundle,让资源缺失以用例失败呈现。
static let bundle: Bundle = Bundle(url: bundleURL) ?? Bundle(for: Marker.self)
/// `Bundle(for:)` 需要类对象;marker 只为取得测试 bundle 的落盘位置。
private final class Marker {}
}
@@ -69,6 +69,7 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase {
listenRestore: RestoredListenSettings(
settings: ActivationFixture.listen, unreadableError: nil),
settingsStore: settingsStore ?? InMemoryProxySettingsStore(),
appBundle: AppArtifact.bundle,
settingsRestore: RestoredProxySettings(
settings: settings ?? ProxySettings(listen: ActivationFixture.listen),
unreadableError: nil),
@@ -121,6 +121,7 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase {
listenRestore: RestoredListenSettings(
settings: ActivationFixture.listen, unreadableError: nil),
settingsStore: InMemoryProxySettingsStore(),
appBundle: AppArtifact.bundle,
settingsRestore: RestoredProxySettings(
settings: settings ?? ProxySettings(listen: ActivationFixture.listen),
unreadableError: nil),
@@ -126,6 +126,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
settingsStore: settingsStore ?? InMemoryProxySettingsStore(),
customRuleStore: customRuleStore
?? CustomRuleStore(fileURL: runtime.directory.appendingPathComponent("custom-rules.json")),
appBundle: AppArtifact.bundle,
settingsRestore: restored,
agent: agent,
probe: probe,
@@ -59,6 +59,7 @@ final class SettingsWorkflowTests: XCTestCase {
listenRestore: RestoredListenSettings(
settings: ActivationFixture.listen, unreadableError: nil),
settingsStore: settingsStore,
appBundle: AppArtifact.bundle,
agent: agent,
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
systemProxy: systemProxy,
@@ -1,4 +1,5 @@
import Foundation
import Security
import XCTest
@testable import ShadowsocksX_NG2
@@ -6,8 +7,9 @@ import XCTest
/// 真实 XPC 装配层的契约 round-trip(issue #71):匿名监听器挂真实 listener
/// delegate(注入假特权写入器,不触真实系统设置)+ listenerEndpoint 连接,覆盖
/// fakes 与纯逻辑单测覆盖不到的层:NSXPCInterface 方法签名、客户端签名
/// identifier 校验、Data payload 传输与 XPC 内部队列应答路径。测试宿主是
/// app bundle,客户端 identifier 与 delegate 期望一致,无需 launchd。
/// identifier 校验、Data payload 传输与 XPC 内部队列应答路径。去宿主化
/// (ADR 0021)后连接方是 xctest runner 本进程,delegate 期望身份按本进程
/// 实际签名 identifier 注入,无需 launchd。
/// 注意:NSXPCListener.delegate 是弱引用,delegate 必须由测试强持有。
final class SystemProxyHelperXPCRoundTripTests: XCTestCase {
func testTypedApplyAndClearRoundTripThroughRealXPCConnection() async throws {
@@ -17,7 +19,9 @@ final class SystemProxyHelperXPCRoundTripTests: XCTestCase {
case .clear: return .cleared
}
})
let delegate = SystemProxyHelperListenerDelegate(engine: engine)
let delegate = SystemProxyHelperListenerDelegate(
engine: engine,
expectedClientIdentifier: try XCTUnwrap(Self.currentProcessSigningIdentifier()))
let listener = NSXPCListener.anonymous()
listener.delegate = delegate
listener.resume()
@@ -35,8 +39,7 @@ final class SystemProxyHelperXPCRoundTripTests: XCTestCase {
let configuration = SystemProxyConfiguration(
socks: .init(host: "127.0.0.1", port: 1086),
http: .init(host: "127.0.0.1", port: 1087),
https: .init(host: "127.0.0.1", port: 1087),
http: .init(host: "127.0.0.1", port: 1087), https: .init(host: "127.0.0.1", port: 1087),
exceptions: ["localhost"])
let payload = try SystemProxyHelperWire.encodeConfiguration(configuration)
@@ -66,4 +69,19 @@ final class SystemProxyHelperXPCRoundTripTests: XCTestCase {
await fulfillment(of: [clearReplied], timeout: 10)
XCTAssertEqual(clearedResponse, .cleared)
}
/// 本进程的代码签名 identifier(与生产 SystemProxyClientValidator 同一
/// SecCode 查询路径,对自身用 SecCodeCopySelf 取 code)。
private static func currentProcessSigningIdentifier() -> String? {
var code: SecCode?
guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return nil }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode
else { return nil }
var information: CFDictionary?
guard SecCodeCopySigningInformation(staticCode, SecCSFlags(), &information) == errSecSuccess,
let information
else { return nil }
return (information as NSDictionary)[kSecCodeInfoIdentifier] as? String
}
}
@@ -45,6 +45,7 @@ final class UnreadableListenSettingsTests: XCTestCase {
plugins: ActivationFixture.plugins,
listenRestore: RestoredListenSettings(
settings: SslocalListenSettings(), unreadableError: listenUnreadable),
appBundle: AppArtifact.bundle,
settingsRestore: settingsRestore,
agent: agent,
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
+33 -2
View File
@@ -85,6 +85,10 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.qiuyuzhou.ShadowsocksX-NG2
PRODUCT_NAME: ShadowsocksX-NG2
# 模块名让位给 Core 镜像库(ShadowsocksX_NG2):两者 swiftmodule 都落
# 共享 Products 目录,重名会互相覆盖;app 可执行自身不 import 任何
# 消费方,改名零影响。
PRODUCT_MODULE_NAME: ShadowsocksX_NG2App
CODE_SIGN_IDENTITY: "Developer ID Application"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: "2.0.0"
@@ -118,6 +122,29 @@ targets:
copy:
destination: wrapper
subpath: Contents/MacOS
ShadowsocksX-NG2Core:
type: library.static
platform: macOS
# 测试专用 app 源码镜像(去宿主化,ADR 0021):与 app target 同源编译
# (除 @main 组合根 MainApp.swift),模块名沿用 ShadowsocksX_NG2,全部
# 测试的 @testable import 零改动。测试 bundle 改链本库后不再生成
# TEST_HOST:xcodebuild test 只构建 app、不再启动 app 进程,与安装版
# 运行实例零接触。同源双编译沿用 Agent/Helper 共享 Domain 文件的既定
# 模式;Core 只被测试 target 依赖,发布链与普通构建不编译它。
sources:
- path: App
excludes:
- "MainApp.swift"
- "Info.plist"
- "ShadowsocksX-NG2.entitlements"
- "Assets.xcassets"
- path: Domain
settings:
base:
PRODUCT_NAME: ShadowsocksX-NG2Core
PRODUCT_MODULE_NAME: ShadowsocksX_NG2
# 静态库产物不签名(app 的 DevID 身份不适用)。
CODE_SIGNING_ALLOWED: NO
ShadowsocksX-NG2Agent:
type: tool
platform: macOS
@@ -177,7 +204,9 @@ targets:
path: Tests/Info.plist
properties: {}
dependencies:
- target: ShadowsocksX-NG2
# 去宿主化(ADR 0021):单测链 Core 静态库镜像而非 app 可执行,测试在
# 独立 xctest runner 中运行,宿主 app 不再被启动。
- target: ShadowsocksX-NG2Core
ShadowsocksX-NG2SmokeTests:
type: bundle.unit-test
platform: macOS
@@ -194,7 +223,9 @@ targets:
path: Tests/Smoke/Info.plist
properties: {}
dependencies:
- target: ShadowsocksX-NG2
# 去宿主化(ADR 0021):smoke 同样链 Core 镜像库;真实 sslocal/agent 从
# scheme 构建的 app 产物取(AppArtifact),宿主 app 不再被启动。
- target: ShadowsocksX-NG2Core
schemes:
# 真实进程冒烟独立 scheme(task test:smoke):拉起真实 sslocal,按需执行;
# 发布门槛要求与 packaging-gate 一起全绿。