import XCTest @testable import ShadowsocksX_NG2 /// 插件区编辑语义经目录工作流 module interface 观察: /// 选择器三态呈现与落盘、「无」整体清除、参数写删钥匙串、集外引用原样保留, /// 以及经编辑面的 ss:// 分享往返。走内存凭据存储与注入 bundleURL 的受管插件 /// 提供。 @MainActor final class CatalogWorkflowPluginTests: XCTestCase { private var workDir: URL! private var bundleRoot: URL! private var fileURL: URL! private var workflow: CatalogWorkflow! override func setUp() async throws { try await super.setUp() workDir = FileManager.default.temporaryDirectory .appendingPathComponent( "catalog-plugin-workflow-tests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) bundleRoot = FileManager.default.temporaryDirectory .appendingPathComponent("catalog-plugin-bundle-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory( at: bundleRoot.appendingPathComponent("Contents/Helpers/Plugins"), withIntermediateDirectories: true) fileURL = workDir.appendingPathComponent("catalog.json") workflow = makeWorkflow() } override func tearDown() async throws { try? FileManager.default.removeItem(at: workDir) try? FileManager.default.removeItem(at: bundleRoot) try await super.tearDown() } private var pluginBinaryName: String { ManagedPluginCatalog.plugins[0].program } private func writePluginBinary() throws { let url = bundleRoot.appendingPathComponent("Contents/Helpers/Plugins/\(pluginBinaryName)") try Data("#!/bin/sh\n".utf8).write(to: url) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } private func makeWorkflow() -> CatalogWorkflow { makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( fileStore: makeTestCatalogStore(fileURL: fileURL), runtime: FakeCatalogRuntime()), plugins: BundleManagedPluginProvider(bundleURL: bundleRoot)) } /// 直建目录(不经 URI 导入)后重载工作流,返回该服务器身份。 private func addPluginServer(program: String?, options: String?) async throws -> NodeID { var catalog = ConfigurationCatalog() var fields = ServerFields( address: "203.0.113.7", port: 8388, encryptionMethod: "aes-256-gcm", password: "password", remark: "带插件") fields.password = "pw" if let program { fields.pluginProgram = program if let options { fields.pluginOptions = options } } try catalog.addServer(fields) try makeTestCatalogStore(fileURL: fileURL).save(CatalogDocument(catalog: catalog)) workflow = makeWorkflow() return try XCTUnwrap(workflow.tree.roots.first?.id) } private func update( _ id: NodeID, plugin: PluginSelection, pluginOptions: String?, address: String = "203.0.113.7" ) async throws { try await workflow.updateServer( id, draft: ServerEditDraft( address: address, port: 8388, encryptionMethod: "aes-256-gcm", password: "pw", remark: "带插件", plugin: plugin, pluginOptions: pluginOptions)) } // MARK: - 选择器呈现 func testManagedSelectionShownWithFactsAndParamsPlaintext() async throws { try writePluginBinary() let id = try await addPluginServer(program: pluginBinaryName, options: "mode=websocket") let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName)) XCTAssertEqual( plugin.programs.first { $0.program == pluginBinaryName }?.availability, .available, "二进制在位即提供" ) XCTAssertTrue(plugin.optionsPresent) XCTAssertEqual(plugin.options, "mode=websocket", "参数明文供输入框预填") XCTAssertEqual( Set(plugin.programs.map(\.program)), Set(["cloak", "cloak-v2", pluginBinaryName, "v2ray-plugin-v1"])) } func testMissingBinaryShowsManagedButNotProvided() async throws { let id = try await addPluginServer(program: pluginBinaryName, options: nil) let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName)) XCTAssertEqual( plugin.programs.first { $0.program == pluginBinaryName }?.availability, .missing, "打包损坏/降级丢插件时点名呈现") } func testUnknownProgramRendersExplicitUnknownState() async throws { let id = try await addPluginServer(program: "obfs-local", options: "obfs=http") let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .unknown(program: "obfs-local"), "集外引用显式未提供") XCTAssertFalse( plugin.programs.contains { $0.program == "obfs-local" && $0.availability == .available }) XCTAssertTrue(plugin.optionsPresent, "已配置参数的事实照常呈现") XCTAssertEqual(plugin.options, "", "集外引用不解析参数明文") } func testNoPluginSelectionIsDefault() async throws { let id = try await addPluginServer(program: nil, options: nil) let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .none) XCTAssertFalse(plugin.optionsPresent) } // MARK: - 落盘语义 func testSelectingManagedPluginSavesProgramAndOptions() async throws { try writePluginBinary() let id = try await addPluginServer(program: nil, options: nil) try await update( id, plugin: .named(program: pluginBinaryName), pluginOptions: "mode=websocket;host=example.com") let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName)) XCTAssertEqual(plugin.options, "mode=websocket;host=example.com") XCTAssertTrue(plugin.optionsPresent) } func testSavingEmptyOptionsDropsOptions() async throws { try writePluginBinary() let id = try await addPluginServer(program: pluginBinaryName, options: "mode=websocket") try await update(id, plugin: .named(program: pluginBinaryName), pluginOptions: " ") let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName), "程序引用保留") XCTAssertFalse(plugin.optionsPresent, "空参数即无参数引用") XCTAssertEqual(plugin.options, "") } func testSelectingNoneClearsProgramAndOptions() async throws { let id = try await addPluginServer(program: pluginBinaryName, options: "mode=websocket") try await update(id, plugin: .none, pluginOptions: nil) let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .none) XCTAssertFalse(plugin.optionsPresent) } func testUnknownSelectionPreservesReferenceAndOptionsVerbatim() async throws { let id = try await addPluginServer(program: "obfs-local", options: "obfs=http") try await update( id, plugin: .unknown(program: "obfs-local"), pluginOptions: nil, address: "198.51.100.9") let form = try XCTUnwrap(try workflow.serverEditForm(for: id)) XCTAssertEqual(form.address, "198.51.100.9", "连接字段照常更新") XCTAssertEqual(form.plugin.selection, .unknown(program: "obfs-local"), "集外引用原样保留") XCTAssertTrue(form.plugin.optionsPresent) XCTAssertEqual(try workflow.serverDetailPluginOptions(for: id), "obfs=http", "参数秘密不动") } func testForgedUnmanagedSelectionIsRejected() async throws { let id = try await addPluginServer(program: nil, options: nil) await expectThrowsAsync( { try await update(id, plugin: .named(program: "obfs-local"), pluginOptions: nil) }, onThrow: { error in XCTAssertEqual(error as? ServerFormError, .pluginUnknown("obfs-local")) }) } // MARK: - 分享往返(与 ss:// URI 编解码一致) func testSharedSsUriRoundTripsThroughEditorEdit() async throws { try writePluginBinary() let id = try await addPluginServer(program: nil, options: nil) try await update( id, plugin: .named(program: pluginBinaryName), pluginOptions: "mode=websocket;host=example.com") let shared = try workflow.shareURI(for: id) let decoded = try SsUri.decode(shared) XCTAssertEqual(decoded.pluginProgram, pluginBinaryName) XCTAssertEqual(decoded.pluginOptions, "mode=websocket;host=example.com") // 导回目录:插件字段同构(新建身份,不按内容去重)。 let outcome = await workflow.importServers(from: [.clipboardText(shared)], into: nil) XCTAssertEqual(outcome.sources.first?.result.importedCount, 1) let importedNode = try XCTUnwrap( workflow.tree.roots.dropFirst().first, "第二个根节点应是新导入的服务器") let imported = try XCTUnwrap(try workflow.serverEditForm(for: importedNode.id)) XCTAssertEqual(imported.plugin.selection, .named(program: pluginBinaryName)) XCTAssertEqual(imported.plugin.options, "mode=websocket;host=example.com") } }