Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowIntegrationTests+SystemProxyInspection.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

400 lines
20 KiB
Swift

import XCTest
@testable import ShadowsocksX_NG2
extension ProxyControlWorkflowIntegrationTests {
private func enabledSystemProxyComposition(
probe: ProxyRuntimeFixture.FakeProbe = .reachable()
) async throws -> Composition {
let server = try makeSeededCatalog()
let composition = makeProxies(probe: probe)
_ = try await composition.catalog.activate(server)
_ = await composition.control.setAgentEnabled(true)
_ = await composition.control.setSystemProxyEnabled(true)
return composition
}
private func waitForSnapshot(
_ composition: Composition, matching predicate: (ProxyControlSnapshot) -> Bool
) async throws {
let deadline = Date().addingTimeInterval(2)
while !predicate(composition.control.snapshot) && Date() < deadline {
try await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(predicate(composition.control.snapshot), "实际快照:\(composition.control.snapshot)")
}
func testCurrentDifferencesIncludeChangedAndNewServicesAndDisappearWhenRestored() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
systemProxy.services = [
SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: "很长的办公室 Wi-Fi 网络接口名称"),
SystemProxyServiceState(
identifier: .init(locationID: "home", serviceID: "ethernet"), configuration: nil,
name: "USB Ethernet"),
]
networkMonitor.emit(.proxyConfiguration)
try await waitForSnapshot(composition) { $0.systemProxyInspection.differences.count == 2 }
XCTAssertEqual(
composition.control.snapshot.systemProxyInspection.differences.map(\.kind),
[.changed, .notApplied])
XCTAssertEqual(
composition.control.snapshot.systemProxyInspection.differences.map(\.name),
["很长的办公室 Wi-Fi 网络接口名称", "USB Ethernet"])
XCTAssertEqual(systemProxy.applied.count, 1)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.canRepair)
systemProxy.services[0] = original
networkMonitor.emit(.proxyConfiguration)
try await waitForSnapshot(composition) { $0.systemProxyInspection.differences.count == 1 }
_ = await composition.control.repairSystemProxy()
XCTAssertEqual(systemProxy.repairedServices, [.init(locationID: "home", serviceID: "ethernet")])
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.differences.isEmpty)
}
func testRepairReadsLatestLocationAndSkipsAlreadyConsistentServices() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: original.name)
_ = await composition.control.recheckSystemProxy()
systemProxy.services = [
SystemProxyServiceState(
identifier: .init(locationID: "work", serviceID: "vpn"), configuration: nil, name: "VPN"),
SystemProxyServiceState(
identifier: .init(locationID: "work", serviceID: "wifi"),
configuration: original.configuration, name: "Wi-Fi"),
]
_ = await composition.control.repairSystemProxy()
XCTAssertEqual(systemProxy.repairedServices, [.init(locationID: "work", serviceID: "vpn")])
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied)
await emitAndWaitForInspection(.networkPath, in: composition)
XCTAssertEqual(systemProxy.applied.count, 2, "被动事件不重写")
}
func testRepairSuccessResponseCannotHideRemainingDifference() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: original.name)
_ = await composition.control.recheckSystemProxy()
systemProxy.preserveConfigurationOnApply = true
_ = await composition.control.repairSystemProxy()
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .changed)
XCTAssertEqual(
composition.control.snapshot.systemProxyInspection.differences.map(\.kind), [.remaining])
}
func testDuplicateRepairDoesNotWriteTwiceAndKeepsNamesDuringProgress() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: original.name)
_ = await composition.control.recheckSystemProxy()
let gate = ProxyControlOperationGate(started: expectation(description: "repair paused"))
defer { gate.resume() }
systemProxy.beforeRepair = { await gate.wait() }
let first = Task { await composition.control.repairSystemProxy() }
await fulfillment(of: [gate.started], timeout: 2)
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .repairing)
XCTAssertEqual(
composition.control.snapshot.systemProxyInspection.differences.map(\.name), ["Wi-Fi"])
XCTAssertFalse(composition.control.snapshot.systemProxyInspection.canRepair)
_ = await composition.control.repairSystemProxy()
gate.resume()
_ = await first.value
XCTAssertEqual(systemProxy.applied.count, 2)
}
func testReadFailureIsUnknownAndRecheckNeverWrites() async throws {
let composition = try await enabledSystemProxyComposition()
systemProxy.readError = SystemProxyError.preferencesUnavailable
let failed = await composition.control.recheckSystemProxy()
XCTAssertEqual(failed.systemProxyApplication, .unreadable(.operation(.preferencesUnavailable)))
XCTAssertFalse(failed.systemProxyInspection.canRepair)
XCTAssertEqual(systemProxy.applied.count, 1)
systemProxy.readError = nil
let restored = await composition.control.recheckSystemProxy()
XCTAssertEqual(restored.systemProxyApplication, .applied)
XCTAssertEqual(systemProxy.applied.count, 1)
}
func testHealthLossClearsOnceAndRecoveryAutomaticallyApplies() async throws {
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
let composition = try await enabledSystemProxyComposition(probe: probe)
probe.setOutcomes([.refused(detail: "local stopped")])
try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused }
XCTAssertTrue(composition.control.snapshot.systemProxyIntentEnabled)
XCTAssertEqual(systemProxy.clearCount, 1)
await emitAndWaitForInspection(.networkPath, in: composition)
XCTAssertEqual(systemProxy.clearCount, 1, "失效期间不循环重试")
probe.setOutcomes([.reachable])
try await waitForSnapshot(composition) { $0.systemProxyApplication == .applied }
XCTAssertEqual(systemProxy.applied.count, 2)
}
func testPausedIntentOffPreventsRecoveryApply() async throws {
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
let composition = try await enabledSystemProxyComposition(probe: probe)
probe.setOutcomes([.refused(detail: "stopped")])
try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused }
let healthObservation = composition.controller.systemProxyObserver.systemProxyHealthTask
XCTAssertNotNil(healthObservation)
_ = await composition.control.setSystemProxyEnabled(false)
probe.setOutcomes([.reachable])
await healthObservation?.value
XCTAssertFalse(networkMonitor.isObserving, "关闭意图后应停止观察与自动恢复")
XCTAssertEqual(systemProxy.applied.count, 1)
XCTAssertFalse(composition.control.snapshot.systemProxyIntentEnabled)
}
func testClearFailureAndApprovalCoexistAfterIntentOffWithoutAutomaticRetry() async throws {
let composition = try await enabledSystemProxyComposition()
systemProxy.clearError = SystemProxyError.helperUnavailable("approval missing")
systemProxyHelper.setStatus(.requiresApproval)
let failed = await composition.control.setSystemProxyEnabled(false)
XCTAssertEqual(failed.systemProxyApplication, .clearFailed(.operation(.helperUnavailable)))
XCTAssertTrue(failed.systemProxyApprovalRequired)
XCTAssertFalse(failed.systemProxyInspection.canRetryClear)
XCTAssertNil(StatusMenuModel.summary(from: failed).systemProxyDetail, "批准原因不重复说明")
systemProxyHelper.setStatus(.approved)
_ = await composition.control.openSystemProxyHelperApproval()
XCTAssertEqual(systemProxy.clearCount, 1)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.canRetryClear)
systemProxy.clearError = nil
let restored = await composition.control.retrySystemProxyClear()
XCTAssertEqual(restored.systemProxyApplication, .idle)
XCTAssertEqual(systemProxy.clearCount, 2)
}
func testFailedApplyApprovalDoesNotRetryButUnattemptedEnableContinues() async throws {
let server = try makeSeededCatalog()
let composition = makeProxies(probe: ProxyRuntimeFixture.FakeProbe.reachable())
_ = try await composition.catalog.activate(server)
_ = await composition.control.setAgentEnabled(true)
systemProxyHelper.setStatus(.requiresApproval)
_ = await composition.control.setSystemProxyEnabled(true)
XCTAssertTrue(systemProxy.applied.isEmpty)
systemProxy.applyError = SystemProxyError.helperUnavailable("connection failed")
systemProxyHelper.setStatus(.approved)
_ = await composition.control.openSystemProxyHelperApproval()
XCTAssertEqual(
composition.control.snapshot.systemProxyApplication, .failed(.operation(.helperUnavailable)))
systemProxyHelper.setStatus(.requiresApproval)
_ = await composition.control.recheckSystemProxy()
XCTAssertTrue(composition.control.snapshot.systemProxyApprovalRequired)
XCTAssertFalse(composition.control.snapshot.systemProxyInspection.canRepair)
systemProxy.applyError = nil
systemProxyHelper.setStatus(.approved)
_ = await composition.control.openSystemProxyHelperApproval()
XCTAssertTrue(systemProxy.applied.isEmpty)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.canRepair)
_ = await composition.control.repairSystemProxy()
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied)
}
func testHealthyStartupOnlyReadsAndNewLocationIsNotAppliedCategory() async throws {
_ = try makeSeededCatalog()
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
settings: ProxySettings(
listen: ActivationFixture.listen, agentEnabled: true, systemProxyEnabled: true),
proxyMode: .direct)
let before = systemProxy.applied.count
await composition.control.resyncOnLaunch()
XCTAssertEqual(systemProxy.applied.count, before)
XCTAssertEqual(systemProxy.clearCount, 0)
systemProxy.services = [
SystemProxyServiceState(
identifier: .init(locationID: "new", serviceID: "wifi"), configuration: nil, name: "Airport"
)
]
networkMonitor.emit(.networkConfiguration)
try await waitForSnapshot(composition) {
$0.systemProxyInspection.differences.map(\.name) == ["Airport"]
}
XCTAssertEqual(
composition.control.snapshot.systemProxyInspection.differences.map(\.kind), [.notApplied])
XCTAssertEqual(systemProxy.applied.count, before)
}
func testUnhealthyStartupClearsAndPreservesIntentWithoutApplying() async throws {
_ = try makeSeededCatalog()
let composition = makeProxies(
probe: ProxyRuntimeFixture.FakeProbe.refusing(),
settings: ProxySettings(
listen: ActivationFixture.listen, agentEnabled: true, systemProxyEnabled: true))
await composition.control.resyncOnLaunch()
XCTAssertEqual(systemProxy.clearCount, 1)
XCTAssertTrue(systemProxy.applied.isEmpty)
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .paused)
XCTAssertTrue(composition.control.snapshot.systemProxyIntentEnabled)
}
func testSavedExceptionsAutomaticallyApplyButDraftAndDisabledIntentDoNot() async throws {
let composition = try await enabledSystemProxyComposition()
let editor = SettingsWorkflow(committing: composition.controller)
_ = editor.beginProxyExceptionsEditing()
XCTAssertEqual(systemProxy.applied.count, 1, "草稿不写系统设置")
_ = await editor.saveProxyExceptions("example.com")
try await waitForSnapshot(composition) { $0.systemProxyApplication == .applied }
XCTAssertEqual(systemProxy.applied.count, 2)
XCTAssertTrue(try XCTUnwrap(systemProxy.applied.last).exceptions.contains("example.com"))
_ = await composition.control.setSystemProxyEnabled(false)
_ = await editor.saveProxyExceptions("example.org")
XCTAssertEqual(systemProxy.applied.count, 2, "关闭意图下保存不应用")
}
func testHealthClearFailurePreservesIntentAndNeedsExplicitRetry() async throws {
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
let composition = try await enabledSystemProxyComposition(probe: probe)
systemProxy.clearError = SystemProxyError.commitFailed("busy")
probe.setOutcomes([.refused(detail: "local stopped")])
try await waitForSnapshot(composition) { $0.systemProxyApplication.isClearFailure }
XCTAssertTrue(composition.control.snapshot.systemProxyIntentEnabled)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.canRetryClear)
XCTAssertFalse(composition.control.snapshot.systemProxyInspection.canRepair)
await emitAndWaitForInspection(.networkConfiguration, in: composition)
XCTAssertEqual(systemProxy.clearCount, 1)
systemProxy.clearError = nil
_ = await composition.control.retrySystemProxyClear()
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .paused)
XCTAssertEqual(systemProxy.clearCount, 2)
}
func testRepairFailureKeepsDifferenceAndApprovalPathUntilExplicitRetry() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: original.name)
_ = await composition.control.recheckSystemProxy()
systemProxy.beforeRepair = { [systemProxyHelper] in
systemProxyHelper?.setStatus(.requiresApproval)
}
systemProxy.applyError = SystemProxyError.helperUnavailable("approval")
let failed = await composition.control.repairSystemProxy()
XCTAssertEqual(failed.systemProxyApplication, .repairFailed(.operation(.helperUnavailable)))
XCTAssertEqual(failed.systemProxyInspection.differences.map(\.name), ["Wi-Fi"])
XCTAssertTrue(failed.systemProxyApprovalRequired)
XCTAssertFalse(failed.systemProxyInspection.canRepair)
systemProxyHelper.setStatus(.approved)
systemProxy.beforeRepair = nil
systemProxy.applyError = nil
try await waitForSnapshot(composition) { $0.systemProxyInspection.canRepair }
XCTAssertEqual(systemProxy.applied.count, 1, "批准后失败操作不自动重试")
_ = await composition.control.repairSystemProxy()
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .applied)
}
func testUnmanagedFieldChangeAndEquivalentWritesStayAppliedWithoutWrites() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
var dictionary = try SystemProxyPlanner.dictionary(
from: original.configuration, serviceID: "wifi")
dictionary["ExternalMetadata"] = "unmanaged"
dictionary.removeValue(forKey: "ProxyAutoDiscoveryEnable")
dictionary["ExceptionsList"] = (dictionary["ExceptionsList"] as? [String])?.reversed().map {
$0
}
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier,
configuration: try SystemProxyPlanner.propertyListData(from: dictionary, serviceID: "wifi"),
name: original.name)
let result = await composition.control.recheckSystemProxy()
XCTAssertEqual(result.systemProxyApplication, .applied)
XCTAssertTrue(result.systemProxyInspection.differences.isEmpty)
XCTAssertEqual(systemProxy.applied.count, 1)
}
func testPassiveReadCannotOverwriteHealthSuspension() async throws {
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
let composition = try await enabledSystemProxyComposition(probe: probe)
let gate = ProxyControlOperationGate(started: expectation(description: "read paused"))
defer { gate.resume() }
systemProxy.beforeRead = { await gate.wait() }
let read = Task { await composition.control.recheckSystemProxy() }
await fulfillment(of: [gate.started], timeout: 2)
probe.setOutcomes([.refused(detail: "stopped")])
try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused }
gate.resume()
_ = await read.value
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .paused)
XCTAssertEqual(systemProxy.clearCount, 1)
}
func testPassiveReadCannotOverwriteHealthClearFailure() async throws {
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
let composition = try await enabledSystemProxyComposition(probe: probe)
systemProxy.clearError = SystemProxyError.commitFailed("busy")
let gate = ProxyControlOperationGate(started: expectation(description: "read paused"))
defer { gate.resume() }
systemProxy.beforeRead = { await gate.wait() }
let read = Task { await composition.control.recheckSystemProxy() }
await fulfillment(of: [gate.started], timeout: 2)
probe.setOutcomes([.refused(detail: "stopped")])
try await waitForSnapshot(composition) { $0.systemProxyApplication.isClearFailure }
gate.resume()
_ = await read.value
XCTAssertEqual(
composition.control.snapshot.systemProxyApplication, .clearFailed(.operation(.commitFailed)))
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.canRetryClear)
}
func testIntentOffDuringRepairPreflightPreventsWrite() async throws {
let composition = try await enabledSystemProxyComposition()
let original = systemProxy.services[0]
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: original.name)
_ = await composition.control.recheckSystemProxy()
let gate = ProxyControlOperationGate(started: expectation(description: "read paused"))
defer { gate.resume() }
systemProxy.beforeRead = { await gate.wait() }
let repair = Task { await composition.control.repairSystemProxy() }
await fulfillment(of: [gate.started], timeout: 2)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.isBusy)
let off = Task { await composition.control.setSystemProxyEnabled(false) }
try await waitForSnapshot(composition) { !$0.systemProxyIntentEnabled }
gate.resume()
_ = await repair.value
_ = await off.value
XCTAssertEqual(systemProxy.applied.count, 1)
XCTAssertEqual(systemProxy.clearCount, 1)
XCTAssertEqual(composition.control.snapshot.systemProxyApplication, .idle)
}
func testHealthLossDuringRepairPreflightPreventsWrite() async throws {
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
let composition = try await enabledSystemProxyComposition(probe: probe)
let original = systemProxy.services[0]
systemProxy.services[0] = SystemProxyServiceState(
identifier: original.identifier, configuration: nil, name: original.name)
_ = await composition.control.recheckSystemProxy()
let gate = ProxyControlOperationGate(started: expectation(description: "read paused"))
defer { gate.resume() }
systemProxy.beforeRead = { await gate.wait() }
let repair = Task { await composition.control.repairSystemProxy() }
await fulfillment(of: [gate.started], timeout: 2)
XCTAssertTrue(composition.control.snapshot.systemProxyInspection.isBusy)
probe.setOutcomes([.refused(detail: "stopped")])
try await waitForSnapshot(composition) { $0.runtime.status != .running }
gate.resume()
_ = await repair.value
try await waitForSnapshot(composition) { $0.systemProxyApplication == .paused }
XCTAssertEqual(systemProxy.applied.count, 1)
XCTAssertEqual(systemProxy.clearCount, 1)
}
func testAgentOffClearFailureKeepsApprovalEntryAfterCascade() async throws {
let composition = try await enabledSystemProxyComposition()
systemProxyHelper.setStatus(.requiresApproval)
systemProxy.clearError = SystemProxyError.helperUnavailable("approval")
let failed = await composition.control.setAgentEnabled(false)
XCTAssertFalse(failed.systemProxyIntentEnabled)
XCTAssertFalse(failed.agentIntentEnabled)
XCTAssertEqual(failed.systemProxyApplication, .clearFailed(.operation(.helperUnavailable)))
XCTAssertTrue(failed.systemProxyApprovalRequired)
XCTAssertFalse(failed.systemProxyInspection.canRetryClear)
}
}