test: move process lifecycle checks to smoke

This commit is contained in:
Qiu Yuzhou
2026-10-24 03:27:28 +08:00
parent 1003b904dc
commit 5d6b8f433f
8 changed files with 99 additions and 87 deletions
@@ -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)
@@ -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
@@ -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<sockaddr_in>.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<sockaddr_in>.size))
}
}
guard result == 0 else { throw POSIXError(.EADDRINUSE) }
var bound = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.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))
}
}
@@ -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<sockaddr_in>.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<sockaddr_in>.size))
}
}
guard result == 0 else { throw POSIXError(.EADDRINUSE) }
var bound = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.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
+1
View File
@@ -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
+1 -1
View File
@@ -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