mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-12 20:25:07 +00:00
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。
345 lines
15 KiB
Swift
345 lines
15 KiB
Swift
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
|
||
}
|
||
}
|