mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 10:13:54 +00:00
Implements issue #71 with ADR-0020 (supersedes ADR-0019's endpoint-signature cleanup, startup cleanup, and health-gate cleanup). - New ShadowsocksX-NG2SystemProxyHelper target: app-bundled root LaunchDaemon (SMAppService.daemon, on-demand via MachServices) that mechanically applies a closed typed configuration or executes an unconditional clear. Clients are accepted by code-signing bundle identifier only; requests run serially on a shared queue (last-writer-wins). - SystemProxyConfiguration is now the closed typed apply payload (Codable over JSON XPC): explicit SOCKS/HTTP/HTTPS enable + endpoints, PAC and auto-discovery disabled with URL/JS removed, explicit simple-hostname exclusion, complete ExceptionsList. Unmodeled Proxies keys are preserved and equivalent values skip the commit. - Cleanup is a side effect of a persisted system-proxy intent on-to-off transition only: the switch itself, or the agent-off cascade (persist both intents off, clear, then stop listeners; failed cascade persistence records no transition and clears nothing). Launch with the switch off, health-gate closure, invalid targets, and passive events never clear. Failed clears are surfaced without deferred retry. - Endpoint-signature store, ownership migration, and direct Authorization Services writes are removed; old signature files stay inert. Helper unavailability or pending approval keeps an enabled intent pending and surfaces the Login Items approval path (HomeView button + menu label).
199 lines
7.7 KiB
Swift
199 lines
7.7 KiB
Swift
import XCTest
|
||
|
||
@testable import ShadowsocksX_NG2
|
||
|
||
/// 状态菜单呈现逻辑(spec #21 D11,issue #31/#47/#60):头部状态摘要从代理
|
||
/// 控制工作流的整体 snapshot 派生,agent 与系统代理两个状态面分开呈现。不
|
||
/// 持有 AppKit 类型;HTTP 导出行与目标路径摘要分别在代理控制与目录 module
|
||
/// 测试覆盖。
|
||
final class StatusMenuModelTests: XCTestCase {
|
||
private struct RuntimeSummaryCase {
|
||
let facts: ProxyRuntimeFacts
|
||
let expectedStatus: String
|
||
let expectedIsOn: Bool
|
||
}
|
||
|
||
private static let runtimeSummaryCases: [RuntimeSummaryCase] = [
|
||
RuntimeSummaryCase(
|
||
facts: ProxyRuntimeFacts(status: .starting, isOn: true),
|
||
expectedStatus: "正在启动代理…",
|
||
expectedIsOn: true),
|
||
RuntimeSummaryCase(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
expectedStatus: "代理运行中",
|
||
expectedIsOn: true),
|
||
RuntimeSummaryCase(
|
||
facts: ProxyRuntimeFacts(
|
||
status: .firewallBlocked,
|
||
isOn: true,
|
||
failure: .firewallBlocked(FirewallBlockedFacts(executableName: "sslocal"))),
|
||
expectedStatus: "代理运行中(局域网受阻)",
|
||
expectedIsOn: true),
|
||
RuntimeSummaryCase(
|
||
facts: ProxyRuntimeFacts(
|
||
status: .launchFailed,
|
||
isOn: false,
|
||
failure: .launch(
|
||
.localEndpoint(
|
||
endpoint: "http", host: "127.0.0.1", port: 11087, cause: .refused))),
|
||
expectedStatus: "启动失败",
|
||
expectedIsOn: false),
|
||
RuntimeSummaryCase(
|
||
facts: ProxyRuntimeFacts(
|
||
status: .requiresApproval, isOn: true, failure: .requiresApproval),
|
||
expectedStatus: "等待允许后台代理",
|
||
expectedIsOn: true),
|
||
RuntimeSummaryCase(
|
||
facts: ProxyRuntimeFacts(
|
||
status: .serviceFailed,
|
||
isOn: false,
|
||
failure: .service(.persistence)),
|
||
expectedStatus: "服务管理失败",
|
||
expectedIsOn: false),
|
||
]
|
||
|
||
/// 从运行时事实构造最小 snapshot(其余字段与本组用例无关)。
|
||
private func makeSnapshot(
|
||
facts: ProxyRuntimeFacts,
|
||
agentIntent: Bool = true,
|
||
activationFailure: ActivationFailure? = nil,
|
||
systemProxyIntent: Bool = false,
|
||
systemProxyApplication: SystemProxyApplicationFacts = .idle,
|
||
systemProxyApprovalRequired: Bool = false,
|
||
mode: ProxyMode = .rule,
|
||
activeTarget: ProxyActiveTargetFacts? = nil
|
||
) -> ProxyControlSnapshot {
|
||
ProxyControlSnapshot(
|
||
runtime: facts,
|
||
agentIntentEnabled: agentIntent,
|
||
activationFailure: activationFailure,
|
||
systemProxyIntentEnabled: systemProxyIntent,
|
||
systemProxyApplication: systemProxyApplication,
|
||
systemProxyApprovalRequired: systemProxyApprovalRequired,
|
||
proxyMode: mode,
|
||
ruleDefaultAction: .proxyWhenUnmatched,
|
||
availableModes: ProxyMode.availableModes,
|
||
activeTarget: activeTarget,
|
||
skippedInvalidServerCount: 0,
|
||
httpExport: HTTPExportCapability(
|
||
copyableLine:
|
||
"export http_proxy=http://127.0.0.1:11087;export https_proxy=http://127.0.0.1:11087;"),
|
||
terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands(
|
||
listen: SslocalListenSettings()))
|
||
}
|
||
|
||
// MARK: - 头部状态摘要
|
||
|
||
func testSummaryRunningStateIsOnWithNoDetail() {
|
||
let summary = StatusMenuModel.summary(
|
||
from: makeSnapshot(facts: ProxyRuntimeFacts(status: .running, isOn: true)))
|
||
XCTAssertTrue(summary.agentIntentEnabled)
|
||
XCTAssertTrue(summary.isOn)
|
||
XCTAssertEqual(summary.status, "代理运行中")
|
||
XCTAssertNil(summary.detail)
|
||
}
|
||
|
||
func testSummaryOffStateIsOff() {
|
||
let summary = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .off, isOn: false),
|
||
agentIntent: false,
|
||
mode: .global))
|
||
XCTAssertFalse(summary.agentIntentEnabled, "开关意图独立于运行状态")
|
||
XCTAssertFalse(summary.isOn)
|
||
XCTAssertEqual(summary.status, "代理未运行")
|
||
XCTAssertNil(summary.detail)
|
||
}
|
||
|
||
func testSummaryProjectsEveryStableRuntimeStatus() {
|
||
for testCase in Self.runtimeSummaryCases {
|
||
let summary = StatusMenuModel.summary(from: makeSnapshot(facts: testCase.facts))
|
||
XCTAssertEqual(summary.isOn, testCase.expectedIsOn)
|
||
XCTAssertEqual(summary.status, testCase.expectedStatus)
|
||
XCTAssertEqual(
|
||
summary.detail,
|
||
testCase.facts.failure.map { AppPresentation.message(for: $0) })
|
||
}
|
||
}
|
||
|
||
/// 激活拒绝/目标清除的点名原因独立于运行状态呈现(issue #60):agent 仍
|
||
/// 在监听时也必须可读。
|
||
func testSummaryCarriesActivationFailureAlongsideRunningState() {
|
||
let summary = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
activationFailure: .targetNotFound(NodeID(rawValue: "manual:gone"))))
|
||
XCTAssertEqual(summary.status, "代理运行中")
|
||
XCTAssertEqual(
|
||
summary.detail,
|
||
AppPresentation.message(
|
||
for: ActivationFailure.targetNotFound(
|
||
NodeID(rawValue: "manual:gone"))))
|
||
}
|
||
|
||
/// 系统代理状态面(issue #60):意图与实际应用分开,失败携带点名原因。
|
||
func testSummaryProjectsSystemProxyApplicationIndependently() {
|
||
let idle = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .off, isOn: false),
|
||
systemProxyIntent: false,
|
||
systemProxyApplication: .idle))
|
||
XCTAssertFalse(idle.systemProxyIntentEnabled)
|
||
XCTAssertEqual(idle.systemProxyStatus, "系统代理:未应用")
|
||
XCTAssertNil(idle.systemProxyDetail)
|
||
|
||
let pending = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
systemProxyIntent: true,
|
||
systemProxyApplication: .pending))
|
||
XCTAssertTrue(pending.systemProxyIntentEnabled)
|
||
XCTAssertEqual(pending.systemProxyStatus, "系统代理:待应用")
|
||
XCTAssertNil(pending.systemProxyDetail)
|
||
|
||
let approvalPending = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
systemProxyIntent: true,
|
||
systemProxyApplication: .pending,
|
||
systemProxyApprovalRequired: true))
|
||
XCTAssertEqual(approvalPending.systemProxyStatus, "系统代理:待批准")
|
||
XCTAssertNil(approvalPending.systemProxyDetail)
|
||
|
||
let applied = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
systemProxyIntent: true,
|
||
systemProxyApplication: .applied))
|
||
XCTAssertEqual(applied.systemProxyStatus, "系统代理:已应用")
|
||
|
||
let failed = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
systemProxyIntent: true,
|
||
systemProxyApplication: .failed(.operation(.applyFailed))))
|
||
XCTAssertEqual(failed.systemProxyStatus, "系统代理:应用失败")
|
||
XCTAssertEqual(
|
||
failed.systemProxyDetail,
|
||
AppPresentation.message(
|
||
for: RuntimeFailureFacts.systemProxy(.operation(.applyFailed))))
|
||
}
|
||
|
||
func testSummaryCarriesModeLabelAndTargetPathFromSnapshot() {
|
||
let summary = StatusMenuModel.summary(
|
||
from: makeSnapshot(
|
||
facts: ProxyRuntimeFacts(status: .running, isOn: true),
|
||
mode: .global,
|
||
activeTarget: ProxyActiveTargetFacts(
|
||
id: NodeID(rawValue: "manual:server"), pathSummary: "订阅分组 / 嵌套分组 / 日本 02")))
|
||
XCTAssertEqual(summary.modeLabel, "全局")
|
||
XCTAssertEqual(summary.targetPath, "订阅分组 / 嵌套分组 / 日本 02")
|
||
}
|
||
|
||
func testSummaryTargetPathIsNilWithoutActiveTarget() {
|
||
let summary = StatusMenuModel.summary(
|
||
from: makeSnapshot(facts: ProxyRuntimeFacts(status: .off, isOn: false)))
|
||
XCTAssertNil(summary.targetPath)
|
||
}
|
||
}
|