Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests.swift
T
Qiu Yuzhou 20a62117f2 refactor(tests): 按功能领域重组 Tests/ 目录
Tests/ 根层原有 138 个平铺测试文件,无法从目录读出测试覆盖的功能领域。

按功能领域重组为 17 个目录(Catalog/Workflow 与 Rules/Custom 两处嵌套,分别
对应 App/Application/CatalogWorkflow 与 Domain/Rules/Custom):Architecture /
Support / Agent / Activation / Catalog / Subscription / Rules / ProxyRuntime /
ProxyControl / Runtime / Settings / SystemProxy / Plugin / Diagnostics /
Credentials / Workspace / Servers;Smoke/ 保持独立 target 不动。138 个移动中
132 个是 100% 相似度的纯重命名,其余 6 个在重命名之上带路径反推改写(见下)。

本次不是纯搬移:测试代码比生产代码多一层位置耦合,有 7 处用固定层数的
deletingLastPathComponent() 从 #filePath 反推仓库路径(都假定文件就在 Tests/
根层)。这 7 处改走新增的 Tests/Support/TestSourceTree.swift——从调用点沿父
目录上溯找到含 project.yml 的目录,因此不再依赖父目录层数,以后移动测试文件
也不会静默指错。

配套改动:

- 新增 Tests 布局守卫 TestsLayoutTests,断言 Tests/ 根层无平铺 .swift。比
  Domain 那条弱一档——根层的 Info.plist(info.path 生成物)与
  Fixtures/RuleSnapshots 下的 JSON(folder resource)都必须保留,不能用
  「树下只有 .swift」覆盖。
- project.yml:Smoke target 按文件引用的 Tests/AppArtifact.swift 改指
  Tests/Support/;两处注释里写死的 Tests/SkeletonTests.swift 随移动更新。
  excludes 的裸名 "Smoke" / "Fixtures" 仍匹配 Tests/ 直属子目录,无需改动。
- README 的 Tests 条目展开为目录说明,并修掉 Domain 条目里写死的
  Tests/SkeletonTests.swift。
- 顺带删去 DomainLayoutTests 文档注释里「整理前 62 个文件」这类易漂移的数字。

验证:xcodegen generate 后单测 target 为 139 个源文件(全部非 Smoke 文件,0 个
Smoke 文件混入)且仍以 Fixtures/RuleSnapshots 为资源,Smoke target 为 8 个
Smoke 文件加 AppArtifact;依赖路径反推的 6 个测试类全绿(路径指错即失败);
task format 未改写任何被移动文件;task lint 与整理前同为 21 条内容型 warning;
build 0 error;task test 1025 用例全通过(整理前 1024,+1 为新守卫);
TestsLayoutTests 经变异验证(根层投放探针即失败);packaging-gate ALL CHECKS
PASSED。
2026-10-05 20:37:35 +08:00

345 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
@testable import ShadowsocksX_NG2
/// 代理控制命令矩阵(issue #47/#60):经生产 runtime adapter 包装的真实控制器
/// 验证 agent 开关、系统代理开关与模式切换的失败分类与恢复语义原样进入
/// snapshot。全部使用 fixture 替身,不启动真实 launch agent、不写真实
/// SystemConfiguration(这些替身测试不构成真实系统写入验证)。
@MainActor
final class ProxyControlWorkflowIntegrationTests: XCTestCase {
private var runtime: ProxyRuntimeFixture.TemporaryRuntime!
private var catalogFileURL: URL!
private var activationFileURL: URL!
private var credentials: InMemoryCredentialStore!
var agent: ProxyRuntimeFixture.FakeLaunchAgent!
var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
var networkMonitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor()
var systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService!
override func setUp() async throws {
try await super.setUp()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
credentials = InMemoryCredentialStore()
agent = ProxyRuntimeFixture.FakeLaunchAgent()
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
systemProxyHelper = ProxyRuntimeFixture.FakeSystemProxyHelperService()
networkMonitor = ProxyRuntimeFixture.FakeSystemProxyNetworkChangeMonitor()
}
override func tearDown() async throws {
try? FileManager.default.removeItem(at: runtime.directory)
try await super.tearDown()
}
/// 控制器、目录工作流与代理控制工作流的生产组合(同一控制器经两条缝)。
struct Composition {
let catalog: CatalogWorkflow
let control: ProxyControlWorkflow
let controller: ProxyRuntimeController
}
/// 建一个含单台服务器的目录并落盘(服务器密码进内存凭据存储)。
func makeSeededCatalog() throws -> NodeID {
var catalog = ConfigurationCatalog()
let server = try ActivationFixture.addPlainServer(
"香港 01", in: &catalog, credentials: credentials)
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
return server
}
func makeProxies(
probe: EndpointProbing,
agentStatus: LaunchAgentStatus = .notRegistered,
settingsStore: ProxySettingsStoring? = nil,
settings: ProxySettings? = nil,
proxyMode: ProxyMode = .rule
) -> Composition {
agent.setStatus(agentStatus)
let runtimeFileStore = RuntimeFileStore(fileURL: runtime.contract)
agent.onRegister = { [runtimeFileStore] in
guard let document = runtimeFileStore.loadDocument() else { return }
try? runtimeFileStore.writeRuntimeReceipt(for: document, processID: 42)
}
let controller = ProxyRuntimeController(
catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader(at: catalogFileURL),
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
runtimeFileStore: runtimeFileStore,
credentials: credentials,
plugins: ActivationFixture.plugins,
listenRestore: RestoredListenSettings(
settings: ActivationFixture.listen, unreadableError: nil),
settingsStore: settingsStore ?? InMemoryProxySettingsStore(),
appBundle: AppArtifact.bundle,
// Keep real ACL compilation; corpus coverage belongs to the packaged-rule tests.
ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot),
settingsRestore: RestoredProxySettings(
settings: settings ?? ProxySettings(listen: ActivationFixture.listen),
unreadableError: nil),
agent: agent, probe: probe,
systemProxy: systemProxy,
systemProxyHelper: systemProxyHelper,
systemProxyNetworkChangeMonitor: networkMonitor,
proxyMode: proxyMode,
firewallChecker: ProxyRuntimeFixture.FakeFirewallChecker(),
firewallExecutableURLs: [URL(fileURLWithPath: "/bundle/Helpers/sslocal")],
firewallPollIntervalNanoseconds: 1_000_000,
// 生产默认 15 秒健康窗;FakeProbe 结果确定,短窗口走同一超时呈现路径。
launchHealthTimeoutSeconds: 0.05,
launchHealthRetryDelay: { try await Task.sleep(for: .milliseconds(1)) },
systemProxyHealthPollIntervalNanoseconds: 20_000_000,
helperRefreshDelayNanoseconds: 0,
sendSignal: { _, _ in 0 },
processIsAlive: { $0 == 42 })
let fileStore = CatalogFileStore(fileURL: catalogFileURL)
let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
let catalogWorkflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(
fileStore: fileStore,
runtime: ProxyRuntimeSyncAdapter(controller: controller),
bootstrap: bootstrap),
credentials: credentials,
plugins: ActivationFixture.plugins,
activator: controller)
let control = ProxyControlWorkflow(
runtime: ControllerProxyRuntimeAdapter(controller: controller),
targetFacts: catalogWorkflow,
interfaceFacts: FakeLocalInterfaceFacts())
return Composition(
catalog: catalogWorkflow, control: control, controller: controller)
}
// MARK: - Agent 开关与首次默认
func testAgentEnableWithoutActiveTargetListensThroughSnapshot() async throws {
_ = try makeSeededCatalog()
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false))
let snapshot = await composition.control.setAgentEnabled(true)
XCTAssertEqual(snapshot.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
XCTAssertTrue(snapshot.agentIntentEnabled, "开关意图进入 snapshot")
XCTAssertTrue(snapshotAgentListening(composition), "空服务器监听契约已部署")
XCTAssertTrue(systemProxy.applied.isEmpty, "系统代理意图默认关闭")
XCTAssertEqual(snapshot.systemProxyApplication, .idle)
XCTAssertEqual(agent.registerCount, 1)
}
/// 空监听判定辅助:契约存在且无服务器。
private func snapshotAgentListening(_ composition: Composition) -> Bool {
guard
let data = try? Data(contentsOf: runtime.contract),
let document = SslocalRuntimeDocument.decodeValidated(data)
else { return false }
return document.servers.isEmpty
}
func testAgentEnableConvergesToWholeRunningSnapshotWithRealCatalogFacts() async throws {
let server = try makeSeededCatalog()
let composition = makeProxies(probe: ProxyRuntimeFixture.FakeProbe.reachable())
// 目录激活走目录工作流(生产 activator 是同一控制器);agent 开关走控制 seam。
_ = try await composition.catalog.activate(server)
let snapshot = await composition.control.setAgentEnabled(true)
XCTAssertEqual(snapshot.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
XCTAssertNil(snapshot.runtime.failure)
XCTAssertTrue(snapshot.agentIntentEnabled)
XCTAssertEqual(snapshot.proxyMode, .rule)
XCTAssertEqual(snapshot.availableModes, [.rule, .global, .direct])
XCTAssertEqual(snapshot.activeTarget?.pathSummary, "香港 01", "活动目标摘要来自真实目录树")
XCTAssertEqual(snapshot.skippedInvalidServerCount, 0)
XCTAssertTrue(
snapshot.httpExport.copyableLine.contains("127.0.0.1:11087"),
"HTTP 导出能力在 snapshot 中就绪")
}
func testUnhealthyEndpointSurfacesTypedLaunchFailureInSnapshot() async throws {
let server = try makeSeededCatalog()
let composition = makeProxies(probe: ProxyRuntimeFixture.FakeProbe.refusing())
_ = try await composition.catalog.activate(server)
let snapshot = await composition.control.setAgentEnabled(true)
XCTAssertEqual(snapshot.runtime.status, .launchFailed)
XCTAssertFalse(snapshot.runtime.isOn)
guard
case .launch(.localEndpoint(let endpoint, let host, _, .refused)) = snapshot.runtime.failure
else {
return XCTFail("应点名本机端点失败,实际 \(String(describing: snapshot.runtime.failure))")
}
XCTAssertEqual(endpoint, "SOCKS")
XCTAssertEqual(host, "127.0.0.1")
}
func testPendingBackgroundApprovalSurfacesTypedFactInSnapshot() async throws {
let server = try makeSeededCatalog()
agent.statusAfterRegister = .requiresApproval
let composition = makeProxies(probe: ProxyRuntimeFixture.FakeProbe.reachable())
_ = try await composition.catalog.activate(server)
let snapshot = await composition.control.setAgentEnabled(true)
XCTAssertEqual(
snapshot.runtime,
ProxyRuntimeFacts(
status: .requiresApproval,
isOn: true,
failure: .requiresApproval))
}
// MARK: - Agent 关闭矩阵
func testDisableReflectsClosedIntentAndKeepsModesAvailable() async throws {
let server = try makeSeededCatalog()
let composition = makeProxies(probe: ProxyRuntimeFixture.FakeProbe.reachable())
_ = try await composition.catalog.activate(server)
_ = await composition.control.setAgentEnabled(true)
let snapshot = await composition.control.setAgentEnabled(false)
XCTAssertEqual(snapshot.runtime, ProxyRuntimeFacts(status: .off, isOn: false))
XCTAssertFalse(snapshot.agentIntentEnabled)
XCTAssertNil(snapshot.runtime.failure)
XCTAssertEqual(snapshot.availableModes, [.rule, .global, .direct], "停用后可用操作不消失")
}
/// 关闭 agent 且系统代理清理失败:agent 停止,清理失败以系统代理 typed fact 呈现。
func testAgentOffWithSystemProxyClearFailureSurfacesTypedFact() async throws {
let server = try makeSeededCatalog()
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settings: ProxySettings(
listen: ActivationFixture.listen, agentEnabled: true, systemProxyEnabled: true))
_ = try await composition.catalog.activate(server)
_ = await composition.control.setAgentEnabled(true)
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied)
systemProxy.clearError = SystemProxyError.commitFailed("busy")
let snapshot = await composition.control.setAgentEnabled(false)
XCTAssertEqual(snapshot.runtime, ProxyRuntimeFacts(status: .off, isOn: false))
XCTAssertEqual(
snapshot.systemProxyApplication, .clearFailed(.operation(.commitFailed)),
"清理失败以 typed fact 呈现,不静默视为已清理")
}
// MARK: - 模式命令矩阵
func testModeSwitchWhileOffPersistsAndSnapshotConvergesWithoutRuntimeTouch() async throws {
_ = try makeSeededCatalog()
let settingsStore = InMemoryProxySettingsStore()
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsStore: settingsStore,
settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false))
let snapshot = await composition.control.setProxyMode(.global)
XCTAssertEqual(snapshot.proxyMode, .global)
XCTAssertEqual(snapshot.runtime, ProxyRuntimeFacts(status: .off, isOn: false))
XCTAssertEqual(settingsStore.saved?.preferredMode, .global, "模式先持久化")
XCTAssertTrue(systemProxy.applied.isEmpty, "agent 未运行时不触碰系统代理")
XCTAssertEqual(systemProxy.clearCount, 0)
}
func testModePersistenceFailureKeepsOldModeAndSurfacesServiceFact() async throws {
_ = try makeSeededCatalog()
let settingsStore = InMemoryProxySettingsStore()
settingsStore.saveError = ProxySettingsStoreError.ioFailure(detail: "disk unavailable")
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsStore: settingsStore,
settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false))
let snapshot = await composition.control.setProxyMode(.global)
XCTAssertEqual(snapshot.proxyMode, .rule, "持久化失败保留旧模式")
XCTAssertEqual(
snapshot.runtime.failure, .service(.persistence), "失败以既有 typed fact 呈现")
XCTAssertNil(settingsStore.saved, "失败不落半程设置")
}
func testSameModeCommandIsNoopWithoutSettingsOrSystemProxyWrites() async throws {
_ = try makeSeededCatalog()
let settingsStore = InMemoryProxySettingsStore()
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settingsStore: settingsStore,
settings: ProxySettings(listen: ActivationFixture.listen, agentEnabled: false))
let before = composition.control.snapshot
let after = await composition.control.setProxyMode(.rule)
XCTAssertEqual(after, before, "同模式命令无副作用,snapshot 不动")
XCTAssertNil(settingsStore.saved)
XCTAssertTrue(systemProxy.applied.isEmpty)
XCTAssertEqual(systemProxy.clearCount, 0)
}
// MARK: - 集成级 model test(deletion 验证,story 38/39)
/// 状态菜单的呈现面只凭 ProxyControlWorkflow 的 snapshot 与 CatalogWorkflow
/// 的目录事实即可完整推导;本文件不要求启动 SwiftUI,也不依赖控制器字段。
func testStatusMenuSummaryIsFullyDerivableFromWorkflowSnapshot() async throws {
let server = try makeSeededCatalog()
let composition = makeProxies(probe: ProxyRuntimeFixture.FakeProbe.reachable())
_ = try await composition.catalog.activate(server)
_ = await composition.control.setAgentEnabled(true)
_ = await composition.control.setSystemProxyEnabled(true)
let summary = StatusMenuModel.summary(from: composition.control.snapshot)
XCTAssertTrue(summary.agentIntentEnabled)
XCTAssertTrue(summary.isOn)
XCTAssertEqual(summary.status, "代理运行中")
XCTAssertEqual(summary.modeLabel, "规则 · 代理")
XCTAssertEqual(summary.targetPath, "香港 01")
XCTAssertNil(summary.detail)
XCTAssertTrue(summary.systemProxyIntentEnabled)
XCTAssertEqual(summary.systemProxyStatus, "系统代理:已应用")
XCTAssertNil(summary.systemProxyDetail)
}
}
/// Pause the first operation at its I/O seam; subsequent reads can finish normally.
@MainActor
final class ProxyControlOperationGate {
let started: XCTestExpectation
private var didPause = false
private var continuation: CheckedContinuation<Void, Never>?
init(started: XCTestExpectation) { self.started = started }
func wait() async {
guard !didPause else { return }
didPause = true
await withCheckedContinuation {
continuation = $0
started.fulfill()
}
}
func resume() {
continuation?.resume()
continuation = nil
}
}
extension ProxyControlWorkflowIntegrationTests {
func emitAndWaitForInspection(
_ change: SystemProxyNetworkChange, in composition: Composition
) async {
XCTAssertTrue(networkMonitor.isObserving)
networkMonitor.emit(change)
let inspection = composition.controller.systemProxyObserver.systemProxyInspectionTask
XCTAssertNotNil(inspection, "被动事件应调度检查")
await inspection?.value
}
}