Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

489 lines
20 KiB
Swift

import Foundation
import XCTest
import MacToolsPluginKit
@testable import MacTools
@MainActor
final class DynamicPluginManagerTests: XCTestCase {
private var temporaryRoot: URL!
private var defaults: UserDefaults!
private let suiteName = "DynamicPluginManagerTests"
override func setUpWithError() throws {
temporaryRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("DynamicPluginManagerTests-\(UUID().uuidString)", isDirectory: true)
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
}
override func tearDownWithError() throws {
if let temporaryRoot {
try? FileManager.default.removeItem(at: temporaryRoot)
}
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
temporaryRoot = nil
}
func testDisablingLoadedPluginRemovesItAndDefersReloadUntilRestart() throws {
let sourceURL = try makePackage(id: "com.example.demo")
let store = makeStore()
_ = try store.installPackage(from: sourceURL)
let plugin = MockDynamicPlugin(id: "com.example.demo")
let loader = StubDynamicPluginLoader { records in
records.map { record in
DynamicPluginLoadResult(record: record, plugins: [plugin], errorMessage: nil)
}
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
XCTAssertEqual(manager.loadInstalledPlugins().map(\.metadata.id), ["com.example.demo"])
manager.setPluginEnabled(false, pluginID: "com.example.demo")
XCTAssertEqual(plugin.deactivationReasons, [.disabled])
XCTAssertTrue(manager.loadInstalledPlugins().isEmpty)
XCTAssertEqual(manager.pluginManagementItems.first?.state, .disabled)
XCTAssertEqual(
manager.pluginManagementItems.first?.detailText,
AppL10n.plugins(
"plugin.detail.restartRequiredAfterRemoval",
defaultValue: "已移出界面,重启后彻底释放已加载代码。"
)
)
}
func testReloadKeepsExistingLoadedPluginInstances() throws {
let sourceURL = try makePackage(id: "com.example.demo")
let store = makeStore()
_ = try store.installPackage(from: sourceURL)
let plugin = MockDynamicPlugin(id: "com.example.demo")
let loader = StubDynamicPluginLoader { records in
records.map { record in
DynamicPluginLoadResult(record: record, plugins: [plugin], errorMessage: nil)
}
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
XCTAssertEqual(manager.loadInstalledPlugins().map(\.metadata.id), ["com.example.demo"])
XCTAssertEqual(loader.receivedRecordIDBatches, [["com.example.demo"]])
XCTAssertEqual(manager.loadInstalledPlugins().map(\.metadata.id), ["com.example.demo"])
XCTAssertEqual(loader.receivedRecordIDBatches, [["com.example.demo"]])
XCTAssertTrue(plugin.deactivationReasons.isEmpty)
}
func testUpdatingLoadedPluginInstallsFilesButDoesNotReloadNativeCodeUntilRestart() throws {
let firstPackageURL = try makePackage(id: "com.example.demo", version: "1.0.0")
let updatePackageURL = try makePackage(id: "com.example.demo", version: "2.0.0")
let store = makeStore()
_ = try store.installPackage(from: firstPackageURL)
let plugin = MockDynamicPlugin(id: "com.example.demo")
let loader = StubDynamicPluginLoader { records in
records.map { record in
DynamicPluginLoadResult(record: record, plugins: [plugin], errorMessage: nil)
}
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
XCTAssertEqual(manager.loadInstalledPlugins().map(\.metadata.id), ["com.example.demo"])
try manager.updatePluginPackage(from: updatePackageURL)
XCTAssertEqual(plugin.deactivationReasons, [.updating])
XCTAssertTrue(manager.loadInstalledPlugins().isEmpty)
XCTAssertEqual(store.installedRecords().first?.manifest.version, "2.0.0")
XCTAssertEqual(manager.pluginManagementItems.first?.state, .restartRequired)
XCTAssertEqual(
manager.pluginManagementItems.first?.detailText,
AppL10n.plugins(
"plugin.detail.restartRequiredAfterUpdate",
defaultValue: "新版本将在重启后启用,旧代码将在重启后彻底释放。"
)
)
}
func testBatchUpdatingLoadedPluginsReloadsOnlyOnce() throws {
let firstAlphaURL = try makePackage(id: "com.example.alpha", version: "1.0.0", displayName: "Alpha")
let firstBetaURL = try makePackage(id: "com.example.beta", version: "1.0.0", displayName: "Beta")
let updateAlphaURL = try makePackage(id: "com.example.alpha", version: "2.0.0", displayName: "Alpha")
let updateBetaURL = try makePackage(id: "com.example.beta", version: "2.0.0", displayName: "Beta")
let store = makeStore()
_ = try store.installPackage(from: firstAlphaURL)
_ = try store.installPackage(from: firstBetaURL)
let alphaPlugin = MockDynamicPlugin(id: "com.example.alpha")
let betaPlugin = MockDynamicPlugin(id: "com.example.beta")
let pluginsByID = [
"com.example.alpha": alphaPlugin,
"com.example.beta": betaPlugin,
]
let loader = StubDynamicPluginLoader { records in
records.compactMap { record in
guard let plugin = pluginsByID[record.id] else {
return nil
}
return DynamicPluginLoadResult(record: record, plugins: [plugin], errorMessage: nil)
}
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
var pluginChangeBatches: [[String]] = []
manager.onPluginsChanged = { plugins in
pluginChangeBatches.append(plugins.map(\.metadata.id))
}
XCTAssertEqual(manager.loadInstalledPlugins().map(\.metadata.id), ["com.example.alpha", "com.example.beta"])
let failures = manager.updatePluginPackages([
(sourceURL: updateAlphaURL, catalogEntry: makeCatalogEntry(id: "com.example.alpha", version: "2.0.0")),
(sourceURL: updateBetaURL, catalogEntry: makeCatalogEntry(id: "com.example.beta", version: "2.0.0")),
])
XCTAssertTrue(failures.isEmpty)
XCTAssertEqual(loader.receivedRecordIDBatches, [
["com.example.alpha", "com.example.beta"],
])
XCTAssertEqual(pluginChangeBatches, [[]])
XCTAssertEqual(alphaPlugin.deactivationReasons, [.updating])
XCTAssertEqual(betaPlugin.deactivationReasons, [.updating])
XCTAssertEqual(
store.installedRecords().map { "\($0.id):\($0.manifest.version)" },
[
"com.example.alpha:2.0.0",
"com.example.beta:2.0.0",
]
)
XCTAssertTrue(manager.pluginManagementItems.allSatisfy { item in
if case .restartRequired = item.state {
return true
}
return false
})
}
func testUninstallingLoadedPluginDeletesPackageAndRemovesManagementItem() throws {
let sourceURL = try makePackage(id: "com.example.demo")
let store = makeStore()
_ = try store.installPackage(from: sourceURL)
let plugin = MockDynamicPlugin(id: "com.example.demo")
let loader = StubDynamicPluginLoader { records in
records.map { record in
DynamicPluginLoadResult(record: record, plugins: [plugin], errorMessage: nil)
}
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
XCTAssertEqual(manager.loadInstalledPlugins().map(\.metadata.id), ["com.example.demo"])
try manager.uninstallPlugin(pluginID: "com.example.demo")
XCTAssertEqual(plugin.deactivationReasons, [.uninstalling])
XCTAssertTrue(manager.loadInstalledPlugins().isEmpty)
XCTAssertTrue(manager.pluginManagementItems.isEmpty)
XCTAssertTrue(store.installedRecords().isEmpty)
}
func testCatalogEntryAddsAvailableManagementItem() throws {
let store = makeStore()
let manager = DynamicPluginManager(packageStore: store, pluginLoader: StubDynamicPluginLoader { _ in [] })
let snapshot = makeCatalogSnapshot(entries: [makeCatalogEntry(id: "com.example.demo", version: "1.0.0")])
manager.rebuildManagementItems(catalogSnapshot: snapshot)
XCTAssertEqual(manager.pluginManagementItems.map(\.id), ["com.example.demo"])
XCTAssertEqual(manager.pluginManagementItems.first?.state, .available)
XCTAssertEqual(manager.pluginManagementItems.first?.canInstall, true)
}
func testInstalledCatalogItemsUseSummaryAsDetailText() throws {
let sourceURL = try makePackage(id: "com.example.demo", version: "1.0.0")
let store = makeStore()
let installedRecord = try store.installPackage(from: sourceURL)
let manager = DynamicPluginManager(packageStore: store, pluginLoader: StubDynamicPluginLoader { _ in [] })
let snapshot = makeCatalogSnapshot(entries: [makeCatalogEntry(id: "com.example.demo", version: "1.0.0")])
manager.rebuildManagementItems(catalogSnapshot: snapshot)
let item = try XCTUnwrap(manager.pluginManagementItems.first)
XCTAssertEqual(item.state, .enabled)
XCTAssertEqual(item.detailText, "示例插件")
XCTAssertNotEqual(item.detailText, installedRecord.packageURL.path)
}
func testInstalledItemDetailKeepsStatusSpecificMessages() {
let packageURL = URL(fileURLWithPath: "/tmp/Demo.mactoolsplugin", isDirectory: true)
let enabledItem = makeManagementItem(state: .enabled, packageURL: packageURL)
let disabledItem = makeManagementItem(state: .disabled, packageURL: packageURL)
let restartingItem = makeManagementItem(
state: .enabled,
packageURL: packageURL,
requiresRestartToFullyUnload: true
)
let updatingItem = makeManagementItem(
state: .updateAvailable(installedVersion: "1.0.0", catalogVersion: "2.0.0"),
packageURL: packageURL
)
XCTAssertEqual(enabledItem.detailText, "示例插件")
XCTAssertEqual(disabledItem.detailText, "示例插件")
XCTAssertNotEqual(enabledItem.detailText, packageURL.path)
XCTAssertNotEqual(disabledItem.detailText, packageURL.path)
XCTAssertEqual(
restartingItem.detailText,
AppL10n.plugins(
"plugin.detail.restartRequiredAfterUpdate",
defaultValue: "新版本将在重启后启用,旧代码将在重启后彻底释放。"
)
)
XCTAssertEqual(
updatingItem.detailText,
AppL10n.pluginsFormat(
"plugin.detail.updateAvailableFormat",
defaultValue: "已安装 %@,可更新到 %@。",
"1.0.0",
"2.0.0"
)
)
}
func testManagementItemsCarryReleaseChannelFromManifestAndCatalog() throws {
let sourceURL = try makePackage(id: "com.example.demo", releaseChannel: "beta")
let store = makeStore()
_ = try store.installPackage(from: sourceURL)
let manager = DynamicPluginManager(packageStore: store, pluginLoader: StubDynamicPluginLoader { _ in [] })
let snapshot = makeCatalogSnapshot(
entries: [makeCatalogEntry(id: "com.example.demo", version: "1.0.0", releaseChannel: "beta")]
)
manager.rebuildManagementItems(catalogSnapshot: snapshot)
XCTAssertEqual(manager.pluginManagementItems.first?.releaseChannel, "beta")
XCTAssertEqual(manager.installedReleaseChannelsByID()["com.example.demo"] ?? nil, "beta")
}
func testCatalogEntryShowsUpdateWhenNewerThanInstalledVersion() throws {
let sourceURL = try makePackage(id: "com.example.demo", version: "1.0.0")
let store = makeStore()
_ = try store.installPackage(from: sourceURL)
let manager = DynamicPluginManager(packageStore: store, pluginLoader: StubDynamicPluginLoader { _ in [] })
_ = manager.loadInstalledPlugins()
let snapshot = makeCatalogSnapshot(entries: [makeCatalogEntry(id: "com.example.demo", version: "2.0.0")])
manager.rebuildManagementItems(catalogSnapshot: snapshot)
XCTAssertEqual(
manager.pluginManagementItems.first?.state,
.updateAvailable(installedVersion: "1.0.0", catalogVersion: "2.0.0")
)
XCTAssertEqual(manager.pluginManagementItems.first?.canUpdate, true)
}
func testLocalDevelopmentCatalogEntryUsesLocalDevelopmentState() throws {
let store = makeStore()
let manager = DynamicPluginManager(packageStore: store, pluginLoader: StubDynamicPluginLoader { _ in [] })
let snapshot = makeCatalogSnapshot(
entries: [makeCatalogEntry(id: "com.example.demo", version: "1.0.0")],
sourceKind: .localDevelopment
)
manager.rebuildManagementItems(catalogSnapshot: snapshot)
XCTAssertEqual(manager.pluginManagementItems.first?.state, .localDevelopment)
XCTAssertEqual(manager.pluginManagementItems.first?.canInstall, true)
}
func testPreviousPluginKitPackageIsNotPassedToLoader() throws {
let sourceURL = try makePackage(
id: "com.example.demo",
version: "1.0.0",
pluginKitVersion: 1
)
let store = makeStore()
let installedURL = store.installedDirectory
.appendingPathComponent("com.example.demo", isDirectory: true)
.appendingPathExtension("mactoolsplugin")
try FileManager.default.copyItem(at: sourceURL, to: installedURL)
let loader = StubDynamicPluginLoader { _ in
XCTFail("Incompatible plugin packages must not be passed to the dynamic loader.")
return []
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
XCTAssertTrue(manager.loadInstalledPlugins().isEmpty)
XCTAssertTrue(loader.receivedRecordIDBatches.isEmpty)
XCTAssertTrue(manager.installedCapabilitiesByID().isEmpty)
XCTAssertEqual(
manager.pluginManagementItems.first?.state,
.incompatible(
AppL10n.pluginsFormat(
"plugin.error.store.installedSDKIncompatibleFormat",
defaultValue: "插件 SDK 版本不兼容,已安装版本为 %d,当前支持版本为 %d。请更新插件。",
1,
PluginPackageManifestLoader.supportedPluginKitVersion
)
)
)
}
func testResumePluginReactivatesWithPackageScopedContext() throws {
let sourceURL = try makePackage(id: "com.example.demo")
let store = makeStore()
_ = try store.installPackage(from: sourceURL)
let plugin = MockDynamicPlugin(id: "com.example.demo")
let loader = StubDynamicPluginLoader { records in
records.map { DynamicPluginLoadResult(record: $0, plugins: [plugin], errorMessage: nil) }
}
let manager = DynamicPluginManager(packageStore: store, pluginLoader: loader)
_ = manager.loadInstalledPlugins()
manager.pausePlugin("com.example.demo")
manager.resumePlugin("com.example.demo")
// Resume must re-activate with the package-scoped context (support
// directory etc.), not a bare nil-directory PluginRuntimeContext(pluginID:).
let expected = store.runtimeContext(for: store.installedRecords().first!)
XCTAssertNotNil(expected.supportDirectory)
XCTAssertEqual(plugin.activationContexts.last?.supportDirectory, expected.supportDirectory)
XCTAssertEqual(plugin.deactivationReasons, [.disabled])
}
private func makeStore() -> PluginPackageStore {
PluginPackageStore(
rootDirectory: temporaryRoot,
userDefaults: defaults,
hostVersion: "1.0.0"
)
}
private func makePackage(
id: String,
version: String = "1.0.0",
displayName: String = "Demo",
bundleRelativePath: String = "Demo.bundle",
pluginKitVersion: Int = PluginPackageManifestLoader.supportedPluginKitVersion,
releaseChannel: String? = nil
) throws -> URL {
let packageURL = temporaryRoot
.appendingPathComponent("Source", isDirectory: true)
.appendingPathComponent("\(id)-\(version)", isDirectory: true)
.appendingPathExtension("mactoolsplugin")
let bundleURL = packageURL.appendingPathComponent(bundleRelativePath, isDirectory: true)
try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true)
let manifest = PluginPackageManifest(
id: id,
displayName: displayName,
version: version,
minHostVersion: "0.1.0",
pluginKitVersion: pluginKitVersion,
bundleRelativePath: bundleRelativePath,
releaseChannel: releaseChannel
)
let data = try JSONEncoder().encode(manifest)
try data.write(to: packageURL.appendingPathComponent("plugin.json"))
return packageURL
}
private func makeCatalogEntry(
id: String,
version: String,
releaseChannel: String? = nil
) -> PluginCatalogEntry {
PluginCatalogEntry(
id: id,
displayName: "Demo",
summary: "示例插件",
version: version,
minimumHostVersion: "0.1.0",
package: PluginCatalogPackage(
url: URL(fileURLWithPath: "/tmp/Demo.mactoolsplugin"),
sha256: String(repeating: "a", count: 64),
size: 42
),
releaseChannel: releaseChannel
)
}
private func makeManagementItem(
state: PluginManagementItem.State,
packageURL: URL,
requiresRestartToFullyUnload: Bool = false
) -> PluginManagementItem {
PluginManagementItem(
id: "com.example.demo",
title: "Demo",
summary: "示例插件",
version: "1.0.0",
state: state,
packageURL: packageURL,
requiresRestartToFullyUnload: requiresRestartToFullyUnload,
releaseNotesURL: nil
)
}
private func makeCatalogSnapshot(
entries: [PluginCatalogEntry],
sourceKind: PluginCatalogSnapshot.SourceKind = .production
) -> PluginCatalogSnapshot {
PluginCatalogSnapshot(
catalog: PluginCatalog(
catalogID: "com.example.catalog",
generatedAt: Date(timeIntervalSince1970: 0),
minimumHostVersion: "0.1.0",
plugins: entries
),
sourceURL: URL(string: "https://example.com/catalog.json")!,
sourceKind: sourceKind,
loadedAt: Date(timeIntervalSince1970: 0)
)
}
}
@MainActor
private final class StubDynamicPluginLoader: DynamicPluginLoading {
private let handler: ([PluginPackageRecord]) -> [DynamicPluginLoadResult]
private(set) var receivedRecordIDBatches: [[String]] = []
init(handler: @escaping ([PluginPackageRecord]) -> [DynamicPluginLoadResult]) {
self.handler = handler
}
func loadInstalledPlugins(from records: [PluginPackageRecord]) -> [DynamicPluginLoadResult] {
receivedRecordIDBatches.append(records.map(\.id))
return handler(records)
}
}
@MainActor
private final class MockDynamicPlugin: MacToolsPlugin {
let metadata: PluginMetadata
var onStateChange: (() -> Void)?
var requestPermissionGuidance: ((String) -> Void)?
var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
private(set) var deactivationReasons: [PluginDeactivationReason] = []
private(set) var activationContexts: [PluginRuntimeContext] = []
init(id: String) {
self.metadata = PluginMetadata(
id: id,
title: "Demo",
iconName: "shippingbox",
iconTint: .blue,
order: 1,
defaultDescription: "Demo"
)
}
func activate(context: PluginRuntimeContext) {
activationContexts.append(context)
}
func deactivate(reason: PluginDeactivationReason) {
deactivationReasons.append(reason)
}
}