22// PluginDirectoryTests.swift
33// BluetoothExplorerPluginEngineTests
44//
5- // Covers the Documents-backed plugin store: first-launch install of the bundled plugins,
6- // idempotence across launches, that a deleted bundled plugin stays deleted , and import validation .
5+ // Covers the Documents-backed store for user-imported plugins: import validation, that a bad
6+ // import writes nothing, deletion , and identifier-to-folder-name sanitising .
77//
8- // Every test runs against a temporary directory — never the real Documents folder.
8+ // Bundled plugins are not stored here (they are referenced read-only from the app bundle), so
9+ // these tests stage a bundled plugin's files into a scratch directory to stand in for a user's
10+ // imported file. Every test runs against a temporary directory — never the real Documents folder.
911//
1012
1113import Foundation
@@ -23,76 +25,33 @@ struct PluginDirectoryTests {
2325 return directory
2426 }
2527
26- private func bundledManifestCount( ) -> Int {
27- let urls = PluginEngineResources . bundle. urls (
28- forResourcesWithExtension: " json " , subdirectory: " Plugins " ) ?? [ ]
29- return urls. map { $0 as URL }
28+ /// Copy one bundled plugin's manifest and module into a fresh scratch directory, standing in
29+ /// for a file the user picked from outside the app. Returns the staged manifest URL.
30+ private func stageBundledPlugin( ) throws -> URL {
31+ let manifestURLs = ( PluginEngineResources . bundle. urls (
32+ forResourcesWithExtension: " json " , subdirectory: " Plugins " ) ?? [ ] )
33+ . map { $0 as URL }
3034 . filter { $0. lastPathComponent. hasSuffix ( PluginDirectory . manifestSuffix) }
31- . count
35+ let manifestURL = try #require( manifestURLs. first)
36+ let manifest = try JSONDecoder ( ) . decode (
37+ PluginManifest . self, from: try Data ( contentsOf: manifestURL) )
38+ let moduleURL = manifestURL. deletingLastPathComponent ( ) . appendingPathComponent ( manifest. module)
39+
40+ let scratch = URL ( fileURLWithPath: NSTemporaryDirectory ( ) )
41+ . appendingPathComponent ( " bleplug-stage- \( UUID ( ) . uuidString) " , isDirectory: true )
42+ try FileManager . default. createDirectory ( at: scratch, withIntermediateDirectories: true )
43+ let stagedManifest = scratch. appendingPathComponent ( manifestURL. lastPathComponent)
44+ try Data ( contentsOf: manifestURL) . write ( to: stagedManifest)
45+ try Data ( contentsOf: moduleURL) . write ( to: scratch. appendingPathComponent ( manifest. module) )
46+ return stagedManifest
3247 }
3348
34- @Test ( " First launch installs every bundled plugin " )
35- func firstLaunchInstalls( ) throws {
36- let directory = try makeTemporaryDirectory ( )
37- defer { try ? FileManager . default. removeItem ( at: directory. url) }
38-
39- #expect( directory. isFirstLaunch)
40- let installed = try directory. installBundledPlugins ( from: PluginEngineResources . bundle)
41- #expect( installed. count == bundledManifestCount ( ) )
42- #expect( installed. isEmpty == false )
43- #expect( directory. isFirstLaunch == false )
44- #expect( directory. installedManifestURLs ( ) . count == installed. count)
45- }
46-
47- @Test ( " Installed plugins load from the directory " )
48- func installedPluginsLoad( ) throws {
49- let directory = try makeTemporaryDirectory ( )
50- defer { try ? FileManager . default. removeItem ( at: directory. url) }
51- try directory. installBundledPlugins ( from: PluginEngineResources . bundle)
52-
53- for manifestURL in directory. installedManifestURLs ( ) {
54- // Hash verification is on: a copy that lost bytes would fail here.
55- _ = try PluginLoader . load ( manifestURL: manifestURL, verifyHash: true )
56- }
57- }
58-
59- @Test ( " A second launch installs nothing new " )
60- func secondLaunchIsIdempotent( ) throws {
61- let directory = try makeTemporaryDirectory ( )
62- defer { try ? FileManager . default. removeItem ( at: directory. url) }
63-
64- let first = try directory. installBundledPlugins ( from: PluginEngineResources . bundle)
65- let second = try directory. installBundledPlugins ( from: PluginEngineResources . bundle)
66- #expect( second. isEmpty, " reinstalled on second launch: \( second) " )
67- #expect( directory. installedManifestURLs ( ) . count == first. count)
68- }
69-
70- @Test ( " A deleted bundled plugin is not resurrected on the next launch " )
71- func deletedPluginStaysDeleted( ) throws {
72- let directory = try makeTemporaryDirectory ( )
73- defer { try ? FileManager . default. removeItem ( at: directory. url) }
74-
75- let installed = try directory. installBundledPlugins ( from: PluginEngineResources . bundle)
76- let victim = try #require( installed. first)
77- try directory. remove ( identifier: victim)
78- #expect( directory. installedManifestURLs ( ) . count == installed. count - 1 )
79-
80- let afterRelaunch = try directory. installBundledPlugins ( from: PluginEngineResources . bundle)
81- #expect( afterRelaunch. isEmpty, " deleted plugin came back: \( afterRelaunch) " )
82- #expect( directory. installedManifestURLs ( ) . count == installed. count - 1 )
83- }
84-
85- @Test ( " Importing a valid plugin copies it into the store " )
49+ @Test ( " Importing a valid plugin copies it into the store and loads " )
8650 func importValidPlugin( ) throws {
87- let source = try makeTemporaryDirectory ( )
8851 let destination = try makeTemporaryDirectory ( )
89- defer {
90- try ? FileManager . default. removeItem ( at: source. url)
91- try ? FileManager . default. removeItem ( at: destination. url)
92- }
93- // Stage a plugin outside the store, the way a user's file would arrive.
94- try source. installBundledPlugins ( from: PluginEngineResources . bundle)
95- let staged = try #require( source. installedManifestURLs ( ) . first)
52+ defer { try ? FileManager . default. removeItem ( at: destination. url) }
53+ let staged = try stageBundledPlugin ( )
54+ defer { try ? FileManager . default. removeItem ( at: staged. deletingLastPathComponent ( ) ) }
9655
9756 let manifest = try destination. importPlugin ( manifestURL: staged)
9857 #expect( destination. installedManifestURLs ( ) . count == 1 )
@@ -104,14 +63,10 @@ struct PluginDirectoryTests {
10463
10564 @Test ( " Importing a plugin with a wrong hash fails and writes nothing " )
10665 func importRejectsBadHash( ) throws {
107- let source = try makeTemporaryDirectory ( )
10866 let destination = try makeTemporaryDirectory ( )
109- defer {
110- try ? FileManager . default. removeItem ( at: source. url)
111- try ? FileManager . default. removeItem ( at: destination. url)
112- }
113- try source. installBundledPlugins ( from: PluginEngineResources . bundle)
114- let staged = try #require( source. installedManifestURLs ( ) . first)
67+ defer { try ? FileManager . default. removeItem ( at: destination. url) }
68+ let staged = try stageBundledPlugin ( )
69+ defer { try ? FileManager . default. removeItem ( at: staged. deletingLastPathComponent ( ) ) }
11570
11671 // Corrupt the manifest's hash in place.
11772 let data = try Data ( contentsOf: staged)
@@ -126,6 +81,22 @@ struct PluginDirectoryTests {
12681 #expect( destination. installedManifestURLs ( ) . isEmpty)
12782 }
12883
84+ @Test ( " An imported plugin can be removed " )
85+ func removeImportedPlugin( ) throws {
86+ let destination = try makeTemporaryDirectory ( )
87+ defer { try ? FileManager . default. removeItem ( at: destination. url) }
88+ let staged = try stageBundledPlugin ( )
89+ defer { try ? FileManager . default. removeItem ( at: staged. deletingLastPathComponent ( ) ) }
90+
91+ let manifest = try destination. importPlugin ( manifestURL: staged)
92+ #expect( destination. installedManifestURLs ( ) . count == 1 )
93+
94+ try destination. remove ( identifier: manifest. identifier)
95+ #expect( destination. installedManifestURLs ( ) . isEmpty)
96+ // Removing something that is not there is a no-op, not an error.
97+ try destination. remove ( identifier: manifest. identifier)
98+ }
99+
129100 @Test ( " Identifiers map to filesystem-safe folder names " )
130101 func folderNames( ) {
131102 #expect( PluginDirectory . folderName ( for: " org.pureswift.plugin.gatt-time " ) == " org.pureswift.plugin.gatt-time " )
0 commit comments