489 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|