From 5d6b8f433fba317c482a05458d521bcfcb77b772 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 24 Oct 2026 03:27:28 +0800 Subject: [PATCH] test: move process lifecycle checks to smoke --- .../AgentLifecycleTests+ChangeProtocol.swift | 0 .../AgentLifecycleTests+CrashRecovery.swift | 3 +- .../AgentLifecycleTests.swift | 7 +- .../ConnectivityRuntimeTests.swift | 0 .../ProxyRuntimeFixture+Document.swift | 89 +++++++++++++++++++ .../Tests/Support/ProxyRuntimeFixture.swift | 84 +---------------- ShadowsocksX-NG2/project.yml | 1 + docs/development.md | 2 +- 8 files changed, 99 insertions(+), 87 deletions(-) rename ShadowsocksX-NG2/Tests/{Agent => Smoke}/AgentLifecycleTests+ChangeProtocol.swift (100%) rename ShadowsocksX-NG2/Tests/{Agent => Smoke}/AgentLifecycleTests+CrashRecovery.swift (96%) rename ShadowsocksX-NG2/Tests/{Agent => Smoke}/AgentLifecycleTests.swift (97%) rename ShadowsocksX-NG2/Tests/{Connectivity => Smoke}/ConnectivityRuntimeTests.swift (100%) create mode 100644 ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture+Document.swift diff --git a/ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests+ChangeProtocol.swift b/ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests+ChangeProtocol.swift similarity index 100% rename from ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests+ChangeProtocol.swift rename to ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests+ChangeProtocol.swift diff --git a/ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests+CrashRecovery.swift b/ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests+CrashRecovery.swift similarity index 96% rename from ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests+CrashRecovery.swift rename to ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests+CrashRecovery.swift index 0c6c0872..8471eb4b 100644 --- a/ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests+CrashRecovery.swift +++ b/ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests+CrashRecovery.swift @@ -62,7 +62,7 @@ extension AgentLifecycleChangeProtocolTests { extension AgentLifecycleChangeProtocolTests { func testReloadWaitsForTermIgnoringChildBeforeStartingReplacement() throws { try writeContract(ProxyRuntimeFixture.makeDocument()) - let wrapper = try launchWrapper(behavior: "ignore-term") + let wrapper = try launchWrapper(behavior: "ignore-term-once") XCTAssertTrue(try waitUntil { self.stateLog().contains("invoked:") }) try writeContract(ProxyRuntimeFixture.makeDocument(localPort: 2086)) kill(wrapper.processIdentifier, SIGUSR1) @@ -88,6 +88,7 @@ extension AgentLifecycleChangeProtocolTests { let wrapper = try launchWrapper(behavior: "drop-acl") XCTAssertEqual(try waitForExit(wrapper), 0) XCTAssertNotNil(store.readFailure(), "资源丢失不能阻止明确失败落盘") + XCTAssertFalse(FileManager.default.fileExists(atPath: store.aclFileURL.path)) let replay = try launchWrapper(behavior: "run") XCTAssertEqual(try waitForExit(replay), 0) XCTAssertEqual(stateLog().components(separatedBy: "invoked:").count, 2) diff --git a/ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests.swift b/ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests.swift similarity index 97% rename from ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests.swift rename to ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests.swift index 6597b2d3..3f478fac 100644 --- a/ShadowsocksX-NG2/Tests/Agent/AgentLifecycleTests.swift +++ b/ShadowsocksX-NG2/Tests/Smoke/AgentLifecycleTests.swift @@ -66,8 +66,11 @@ class AgentLifecycleTests: XCTestCase { crash) exit 7 ;; exit0) exit 0 ;; drop-acl) rm -f "$SSXNG_RUNTIME_DIR"/acl-0*.ini - while :; do sleep 0.05; done ;; - ignore-term) trap 'echo "SIGTERM ignored" >> "$SSLOCAL_STUB_STATE"' TERM + exit 7 ;; + ignore-term-once) if [ ! -f "$SSLOCAL_STUB_PID_DIR/term-ignored" ]; then + touch "$SSLOCAL_STUB_PID_DIR/term-ignored" + trap 'echo "SIGTERM ignored" >> "$SSLOCAL_STUB_STATE"' TERM + fi while :; do sleep 0.05; done ;; *) while :; do sleep 0.05; done ;; esac diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityRuntimeTests.swift b/ShadowsocksX-NG2/Tests/Smoke/ConnectivityRuntimeTests.swift similarity index 100% rename from ShadowsocksX-NG2/Tests/Connectivity/ConnectivityRuntimeTests.swift rename to ShadowsocksX-NG2/Tests/Smoke/ConnectivityRuntimeTests.swift diff --git a/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture+Document.swift b/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture+Document.swift new file mode 100644 index 00000000..a4fc4e8c --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture+Document.swift @@ -0,0 +1,89 @@ +import Darwin +import Foundation + +@testable import ShadowsocksX_NG2 + +enum ProxyRuntimeFixture { + static func unusedLoopbackPort() throws -> Int { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.ENOTSOCK) } + defer { close(descriptor) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { throw POSIXError(.EADDRINUSE) } + var bound = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(descriptor, $0, &length) + } + } + guard nameResult == 0 else { throw POSIXError(.EINVAL) } + return Int(CFSwapInt16BigToHost(bound.sin_port)) + } + + /// 夹具默认监听端口:测试进程内一次性分配的空闲端口对。不能固定 11086/ + /// 11087(与 app 默认监听相同):宿主 app 在跑时其 sslocal 占住这两个端口, + /// wrapper 健康探测会误判「监听已建立」。进程内共享同一对端口而非逐调用 + /// 分配,保证两次默认构造之间除显式参数外无差异——「仅服务器变化」类 + /// 变更协议语义依赖这一点。 + private static let defaultListenPorts: (socks: Int, http: Int) = { + func freePort() -> Int { + guard let port = try? unusedLoopbackPort() else { + preconditionFailure("夹具端口分配失败:socket 不可用(fd 耗尽?)") + } + return port + } + let socks = freePort() + var http = freePort() + while http == socks { + http = freePort() + } + return (socks, http) + }() + + static func makeDocument( + serverAddress: String = "203.0.113.7", + password: String = "resolved-password", + pluginOpts: String? = nil, + listenerMode: ListenerMode = .disabled, + localPort: Int? = nil, + inboundProtocol: String = "socks" + ) -> SslocalRuntimeDocument { + precondition(inboundProtocol == "socks") + let socksPort: Int + let httpPort: Int + if let localPort { + socksPort = localPort + httpPort = ProxyListeningConfiguration.defaultHTTPPort + } else { + socksPort = defaultListenPorts.socks + httpPort = defaultListenPorts.http + } + return SslocalRuntimeDocument( + servers: [ + SslocalServerDocument( + id: "server-1", + server: serverAddress, + serverPort: 8388, + password: password, + method: "aes-256-gcm", + plugin: nil, + pluginOpts: pluginOpts + ) + ], + listen: ProxyListeningConfiguration( + listenerMode: listenerMode, + socksPort: socksPort, + httpPort: httpPort)) + } + +} diff --git a/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift b/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift index f65ba2a6..931e9f01 100644 --- a/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift +++ b/ShadowsocksX-NG2/Tests/Support/ProxyRuntimeFixture.swift @@ -23,7 +23,7 @@ final class ProxyRuntimeEventLog: @unchecked Sendable { } } -enum ProxyRuntimeFixture { +extension ProxyRuntimeFixture { /// 在临时目录中建一套隔离的运行时文件组(不触碰真实 ~/Library)。 struct TemporaryRuntime { let directory: URL @@ -49,88 +49,6 @@ enum ProxyRuntimeFixture { .catalogSnapshotReader } - static func unusedLoopbackPort() throws -> Int { - let descriptor = socket(AF_INET, SOCK_STREAM, 0) - guard descriptor >= 0 else { throw POSIXError(.ENOTSOCK) } - defer { close(descriptor) } - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = 0 - address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) - } - } - guard result == 0 else { throw POSIXError(.EADDRINUSE) } - var bound = sockaddr_in() - var length = socklen_t(MemoryLayout.size) - let nameResult = withUnsafeMutablePointer(to: &bound) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getsockname(descriptor, $0, &length) - } - } - guard nameResult == 0 else { throw POSIXError(.EINVAL) } - return Int(CFSwapInt16BigToHost(bound.sin_port)) - } - - /// 夹具默认监听端口:测试进程内一次性分配的空闲端口对。不能固定 11086/ - /// 11087(与 app 默认监听相同):宿主 app 在跑时其 sslocal 占住这两个端口, - /// wrapper 健康探测会误判「监听已建立」。进程内共享同一对端口而非逐调用 - /// 分配,保证两次默认构造之间除显式参数外无差异——「仅服务器变化」类 - /// 变更协议语义依赖这一点。 - private static let defaultListenPorts: (socks: Int, http: Int) = { - func freePort() -> Int { - guard let port = try? unusedLoopbackPort() else { - preconditionFailure("夹具端口分配失败:socket 不可用(fd 耗尽?)") - } - return port - } - let socks = freePort() - var http = freePort() - while http == socks { - http = freePort() - } - return (socks, http) - }() - - static func makeDocument( - serverAddress: String = "203.0.113.7", - password: String = "resolved-password", - pluginOpts: String? = nil, - listenerMode: ListenerMode = .disabled, - localPort: Int? = nil, - inboundProtocol: String = "socks" - ) -> SslocalRuntimeDocument { - precondition(inboundProtocol == "socks") - let socksPort: Int - let httpPort: Int - if let localPort { - socksPort = localPort - httpPort = ProxyListeningConfiguration.defaultHTTPPort - } else { - socksPort = defaultListenPorts.socks - httpPort = defaultListenPorts.http - } - return SslocalRuntimeDocument( - servers: [ - SslocalServerDocument( - id: "server-1", - server: serverAddress, - serverPort: 8388, - password: password, - method: "aes-256-gcm", - plugin: nil, - pluginOpts: pluginOpts - ) - ], - listen: ProxyListeningConfiguration( - listenerMode: listenerMode, - socksPort: socksPort, - httpPort: httpPort)) - } - /// LaunchAgent 注册态可编程替身,记录全部调用。 final class FakeLaunchAgent: LaunchAgentControlling { private(set) var currentStatus: LaunchAgentStatus diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index 990f7882..36778deb 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -286,6 +286,7 @@ targets: # app 产物定位 helper(与单测 target 共享,见 Tests/Support/AppArtifact.swift)。 # 同样是显式文件路径:移动它必须同步此处,否则 xcodegen 以缺文件失败。 - path: Tests/Support/AppArtifact.swift + - path: Tests/Support/ProxyRuntimeFixture+Document.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.qiuyuzhou.ShadowsocksX-NG2.SmokeTests diff --git a/docs/development.md b/docs/development.md index 01a7cb10..93daf3bc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -64,7 +64,7 @@ Swift Package Manager resolves the pinned release of AgeSwift from `project.yml` You can build and run unit tests without a certificate: the Debug baseline uses ad-hoc signing and does not require `Configs/Signing.local.xcconfig`. Create that file only when signing with Developer ID, either for release or to make a local Debug build pass the packaging gate. See [Signing and runtime baseline](distribution.md#signing-and-runtime-baseline) for its contents. -`Tests/Smoke/` contains real-process smoke tests. A wrapper launches real `sslocal` to verify startup, handshake, ACL routing, and plugin supervision. These tests are not part of the default `task test`; run them locally with their separate scheme. CI runs them as an independent job. Pending reload-signal accumulation is tested without child processes in `RuntimeReloadStateTests`; real signal delivery and combined server/ACL routing remain covered by `RuntimePublicationSmokeTests`. +`Tests/Smoke/` contains real-process smoke tests. A wrapper launches real `sslocal` to verify startup, handshake, ACL routing, and plugin supervision. Agent lifecycle tests use the packaged Agent with shell stubs to verify signals, restart ordering, startup deadlines, and persisted failures. Connectivity runtime cleanup tests also run here. These tests are not part of the default `task test`; run them locally with their separate scheme. CI runs them as an independent job. Pending reload-signal accumulation is tested without child processes in `RuntimeReloadStateTests`; real signal delivery and combined server/ACL routing remain covered by `RuntimePublicationSmokeTests`. ## Writing and optimizing tests