Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowTests.swift
T

336 lines
14 KiB
Swift

import Combine
import XCTest
@testable import ShadowsocksX_NG2
// MARK: - 确定性替身(snapshot 与命令 contract;不触真实运行时)
// 供本文件与 ProxyCommandAddressWorkflowTests 共用(测试 target 内部)。
/// 可编程 runtime 替身:事实可编程、命令全记录;
/// changes 为手动 subject,同步发值保证观察测试确定性。
@MainActor
final class FakeProxyRuntime: ProxyRuntimeAdapting {
var runtimeFacts: ProxyRuntimeFacts
var agentIntentEnabled: Bool
var activationFailure: ActivationFailure?
var systemProxyIntentEnabled: Bool
var systemProxyApplication: SystemProxyApplicationFacts
var systemProxyApprovalRequired = false
var systemProxyInspection = SystemProxyInspectionFacts()
var proxyMode: ProxyMode
var ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched
var skippedInvalidServerCount = 0
var activeTargetID: NodeID?
var httpExportCapability = HTTPExportCapability(
copyableLine:
"export http_proxy=http://127.0.0.1:11087;export https_proxy=http://127.0.0.1:11087;")
var listenFacts = RuntimeListenFacts(listen: SslocalListenSettings())
private let changeSubject = PassthroughSubject<Void, Never>()
var changes: AnyPublisher<Void, Never> { changeSubject.eraseToAnyPublisher() }
private(set) var resyncCount = 0
private(set) var agentCommands: [Bool] = []
private(set) var systemProxyCommands: [Bool] = []
private(set) var approvalCommandCount = 0
private(set) var modeCommands: [ProxyMode] = []
private(set) var ruleDefaultActionCommands: [RuleDefaultAction] = []
init(
facts: ProxyRuntimeFacts = ProxyRuntimeFacts(status: .off, isOn: false),
agentIntent: Bool = true,
systemProxyIntent: Bool = false,
systemProxyApplication: SystemProxyApplicationFacts = .idle,
mode: ProxyMode = .rule
) {
runtimeFacts = facts
agentIntentEnabled = agentIntent
systemProxyIntentEnabled = systemProxyIntent
self.systemProxyApplication = systemProxyApplication
proxyMode = mode
}
func emitChange() { changeSubject.send() }
func resyncOnLaunch() async { resyncCount += 1 }
func setAgentEnabled(_ enabled: Bool) async { agentCommands.append(enabled) }
func setSystemProxyEnabled(_ enabled: Bool) async { systemProxyCommands.append(enabled) }
func openSystemProxyHelperApproval() async { approvalCommandCount += 1 }
func repairSystemProxy() async {}
func retrySystemProxyClear() async {}
func recheckSystemProxy() async {}
func setProxyMode(_ mode: ProxyMode) async { modeCommands.append(mode) }
func setRuleDefaultAction(_ action: RuleDefaultAction) async {
ruleDefaultActionCommands.append(action)
}
}
/// 可编程目标事实替身:路径摘要可编程、查询全记录。
@MainActor
final class FakeTargetFacts: ProxyTargetFactsReading {
var pathByTarget: [NodeID: String] = [:]
private(set) var queriedTargetIDs: [NodeID?] = []
func activeTargetFacts(for targetID: NodeID?) -> ProxyActiveTargetFacts? {
queriedTargetIDs.append(targetID)
guard let targetID else { return nil }
return ProxyActiveTargetFacts(id: targetID, pathSummary: pathByTarget[targetID])
}
}
/// 代理控制工作流的 snapshot 与命令 contract:整体一致性、
/// typed commands、窄目录事实缝、运行时变化重观察与命令边界。
@MainActor
final class ProxyControlWorkflowTests: XCTestCase {
private var runtime: FakeProxyRuntime!
private var targetFacts: FakeTargetFacts!
private var interfaceFacts: FakeLocalInterfaceFacts!
private var workflow: ProxyControlWorkflow!
override func setUp() async throws {
try await super.setUp()
runtime = FakeProxyRuntime()
targetFacts = FakeTargetFacts()
interfaceFacts = FakeLocalInterfaceFacts()
workflow = ProxyControlWorkflow(
runtime: runtime, targetFacts: targetFacts, interfaceFacts: interfaceFacts)
}
private static let serverID = NodeID(rawValue: "manual:server-1")
/// 默认监听设置下的预期完整命令(字面量组装,独立于生产派生路径)。
private static let defaultLoopbackCommands = TerminalProxyEnvironmentCommands(
zshBash:
"export http_proxy='http://127.0.0.1:11087'; "
+ "export https_proxy='http://127.0.0.1:11087'; "
+ "export all_proxy='socks5://127.0.0.1:11086'; "
+ "export no_proxy='localhost,127.0.0.1,::1,.local';",
fish:
"set -gx http_proxy 'http://127.0.0.1:11087'; "
+ "set -gx https_proxy 'http://127.0.0.1:11087'; "
+ "set -gx all_proxy 'socks5://127.0.0.1:11086'; "
+ "set -gx no_proxy 'localhost,127.0.0.1,::1,.local';",
nushell:
"$env.http_proxy = 'http://127.0.0.1:11087'; "
+ "$env.https_proxy = 'http://127.0.0.1:11087'; "
+ "$env.all_proxy = 'socks5://127.0.0.1:11086'; "
+ "$env.no_proxy = 'localhost,127.0.0.1,::1,.local';",
windowsCMD:
"set \"http_proxy=http://127.0.0.1:11087\" & "
+ "set \"https_proxy=http://127.0.0.1:11087\" & "
+ "set \"all_proxy=socks5://127.0.0.1:11086\" & "
+ "set \"no_proxy=localhost,127.0.0.1,::1,.local\"",
windowsPowerShell:
"$env:http_proxy = 'http://127.0.0.1:11087'; "
+ "$env:https_proxy = 'http://127.0.0.1:11087'; "
+ "$env:all_proxy = 'socks5://127.0.0.1:11086'; "
+ "$env:no_proxy = 'localhost,127.0.0.1,::1,.local';")
// MARK: - 整体 snapshot
func testSnapshotAssemblesAllFactsFromSingleObservation() {
runtime.runtimeFacts = ProxyRuntimeFacts(
status: .running,
isOn: true,
failure: .firewallBlocked(FirewallBlockedFacts(executableName: "sslocal")))
runtime.agentIntentEnabled = true
runtime.activationFailure = .noActiveTarget
runtime.systemProxyIntentEnabled = true
runtime.systemProxyApplication = .pending
runtime.proxyMode = .global
runtime.skippedInvalidServerCount = 3
runtime.activeTargetID = Self.serverID
runtime.httpExportCapability = HTTPExportCapability(
copyableLine: "export http_proxy=http://127.0.0.1:11087;")
targetFacts.pathByTarget[Self.serverID] = "组A / 香港 01"
runtime.emitChange()
let loopback = TerminalCommandAddress(bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")
XCTAssertEqual(
workflow.snapshot,
ProxyControlSnapshot(
runtime: ProxyRuntimeFacts(
status: .running,
isOn: true,
failure: .firewallBlocked(FirewallBlockedFacts(executableName: "sslocal"))),
agentIntentEnabled: true,
activationFailure: .noActiveTarget,
systemProxyIntentEnabled: true,
systemProxyApplication: .pending,
systemProxyApprovalRequired: false,
proxyMode: .global,
ruleDefaultAction: .proxyWhenUnmatched,
availableModes: ProxyMode.availableModes,
activeTarget: ProxyActiveTargetFacts(id: Self.serverID, pathSummary: "组A / 香港 01"),
skippedInvalidServerCount: 3,
httpExport: HTTPExportCapability(
copyableLine: "export http_proxy=http://127.0.0.1:11087;"),
commandAddressPicker: TerminalCommandAddressPicker(
isVisible: false, candidates: [loopback], selected: loopback),
terminalProxyEnvironmentCommands: Self.defaultLoopbackCommands))
XCTAssertEqual(
workflow.snapshot.availableModes, ProxyMode.availableModes,
"可用模式是 issue #46 Domain 单点策略的投影")
}
// MARK: - typed commands:转发并整体重发布
func testAgentEnableCommandForwardsAndRepublishesWholeSnapshot() async {
let before = workflow.snapshot
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
runtime.agentIntentEnabled = true
runtime.skippedInvalidServerCount = 2
runtime.activeTargetID = Self.serverID
let after = await workflow.setAgentEnabled(true)
XCTAssertEqual(runtime.agentCommands, [true])
XCTAssertEqual(after, workflow.snapshot, "命令返回值就是已发布的最新 snapshot")
XCTAssertNotEqual(after, before, "命令完成后整体重发布")
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
XCTAssertEqual(after.skippedInvalidServerCount, 2)
}
func testAgentDisableCommandForwardsAndRepublishesWholeSnapshot() async {
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
runtime.agentIntentEnabled = true
_ = await workflow.setAgentEnabled(true)
runtime.runtimeFacts = ProxyRuntimeFacts(status: .off, isOn: false)
runtime.agentIntentEnabled = false
runtime.skippedInvalidServerCount = 0
runtime.activeTargetID = nil
let after = await workflow.setAgentEnabled(false)
XCTAssertEqual(runtime.agentCommands, [true, false])
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .off, isOn: false))
XCTAssertFalse(after.agentIntentEnabled)
XCTAssertNil(after.activeTarget)
}
/// 系统代理命令与 agent 命令互不触碰对方事实。
func testSystemProxyCommandsForwardIndependentlyFromAgentCommands() async {
runtime.agentIntentEnabled = true
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
_ = await workflow.setAgentEnabled(true)
runtime.systemProxyIntentEnabled = true
runtime.systemProxyApplication = .applied
let applied = await workflow.setSystemProxyEnabled(true)
XCTAssertEqual(runtime.systemProxyCommands, [true])
XCTAssertTrue(applied.systemProxyIntentEnabled)
XCTAssertEqual(applied.systemProxyApplication, .applied)
XCTAssertEqual(applied.agentIntentEnabled, true, "agent 意图不被系统代理命令触碰")
runtime.systemProxyIntentEnabled = false
runtime.systemProxyApplication = .idle
let off = await workflow.setSystemProxyEnabled(false)
XCTAssertEqual(runtime.systemProxyCommands, [true, false])
XCTAssertFalse(off.systemProxyIntentEnabled)
XCTAssertEqual(off.systemProxyApplication, .idle)
XCTAssertEqual(runtime.agentCommands, [true], "系统代理命令不触发 agent 命令")
}
func testModeCommandForwardsAndRepublishesWholeSnapshot() async {
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
_ = await workflow.setAgentEnabled(true)
runtime.proxyMode = .global
let after = await workflow.setProxyMode(.global)
XCTAssertEqual(runtime.modeCommands, [.global])
XCTAssertEqual(after.proxyMode, .global)
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
}
func testRuleDefaultActionCommandForwardsAndRepublishes() async {
runtime.ruleDefaultAction = .proxyWhenUnmatched
_ = await workflow.setRuleDefaultAction(.directWhenUnmatched)
XCTAssertEqual(runtime.ruleDefaultActionCommands, [.directWhenUnmatched])
// 命令完成后 workflow 从 runtime 重读事实。
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .proxyWhenUnmatched)
runtime.ruleDefaultAction = .directWhenUnmatched
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .directWhenUnmatched)
XCTAssertEqual(workflow.snapshot.proxyMode, runtime.proxyMode)
}
func testResyncOnLaunchForwardsAndRepublishes() async {
let before = workflow.snapshot
runtime.runtimeFacts = ProxyRuntimeFacts(
status: .running, isOn: true, failure: nil)
await workflow.resyncOnLaunch()
XCTAssertEqual(runtime.resyncCount, 1)
XCTAssertNotEqual(workflow.snapshot, before)
XCTAssertEqual(workflow.snapshot.runtime.status, .running)
}
// MARK: - 运行时变化重观察
func testRuntimeChangeRepublishesWholeSnapshotWithoutCommand() {
runtime.runtimeFacts = ProxyRuntimeFacts(status: .starting, isOn: true)
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.runtime.status, .starting)
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.runtime.status, .running)
XCTAssertTrue(workflow.snapshot.runtime.isOn)
}
// MARK: - 窄目录目标事实缝
func testActiveTargetFactsComeFromNarrowCatalogSeam() {
runtime.activeTargetID = Self.serverID
targetFacts.pathByTarget[Self.serverID] = "订阅分组 / 香港 01"
runtime.emitChange()
XCTAssertEqual(
workflow.snapshot.activeTarget,
ProxyActiveTargetFacts(id: Self.serverID, pathSummary: "订阅分组 / 香港 01"))
XCTAssertEqual(targetFacts.queriedTargetIDs.last, Self.serverID)
}
func testNoActiveTargetYieldsNilTargetFacts() {
XCTAssertNil(workflow.snapshot.activeTarget)
XCTAssertEqual(targetFacts.queriedTargetIDs, [nil] as [NodeID?], "init 观察即查询一次")
}
func testTargetMissingFromTreeKeepsExistenceWithNilSummary() {
runtime.activeTargetID = Self.serverID
targetFacts.pathByTarget[Self.serverID] = nil
runtime.emitChange()
let target = workflow.snapshot.activeTarget
XCTAssertEqual(target?.id, Self.serverID, "存在性来自活动目标身份")
XCTAssertNil(target?.pathSummary, "目标不在当前目录树中时摘要为 nil")
}
// MARK: - 命令边界
func testCommandsTouchOnlyRuntimeAndTargetFactsAdapters() async {
await workflow.setAgentEnabled(true)
await workflow.setSystemProxyEnabled(true)
await workflow.setProxyMode(.global)
await workflow.resyncOnLaunch()
XCTAssertEqual(runtime.agentCommands, [true])
XCTAssertEqual(runtime.systemProxyCommands, [true])
XCTAssertEqual(runtime.modeCommands, [.global])
XCTAssertEqual(runtime.resyncCount, 1)
// 目标缝只被 snapshot 组装读取;目录激活、订阅刷新、设置保存与诊断导出
// 不在本 module 的依赖面上(类型层面不存在这些入口)。
XCTAssertFalse(targetFacts.queriedTargetIDs.isEmpty, "每次观察经窄缝取目标事实")
}
}