From ed676787f0a0a26e23a10548eb841bc15411fa52 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 29 Dec 2024 07:20:39 -0500 Subject: [PATCH 1/4] Use Swift immutable instead of mutable data. Use `struct` instead of `class`. Requires switching `SoftwareMapAppLibrary.installedApps` property from lazy to eagerly initialized. Use `let` instead of `var`. Inline unnecessary variables. Improve `Bundle` resource reading. Restrict symbol visibility. Simplify code. Partial #686 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .../AppStore/PurchaseDownloadObserver.swift | 14 +--- .../ITunesSearchAppStoreSearcher.swift | 9 ++- .../Controllers/SoftwareMapAppLibrary.swift | 15 ++-- Sources/mas/Formatters/Utilities.swift | 2 +- Sources/mas/Network/NetworkManager.swift | 2 +- Tests/masTests/Commands/HomeSpec.swift | 7 +- Tests/masTests/Commands/InfoSpec.swift | 11 +-- Tests/masTests/Commands/OpenSpec.swift | 7 +- Tests/masTests/Commands/OutdatedSpec.swift | 26 +++--- Tests/masTests/Commands/SearchSpec.swift | 11 +-- Tests/masTests/Commands/UninstallSpec.swift | 20 ++--- Tests/masTests/Commands/VendorSpec.swift | 7 +- .../masTests/Controllers/MockAppLibrary.swift | 16 ++-- .../Controllers/MockAppStoreSearcher.swift | 12 +-- .../SoftwareMapAppLibrarySpec.swift | 23 +++--- Tests/masTests/Errors/MASErrorTestCase.swift | 80 +++++-------------- Tests/masTests/Extensions/Bundle+JSON.swift | 28 +------ .../Extensions/String+FileExtension.swift | 21 ----- .../Formatters/AppListFormatterSpec.swift | 43 +++++----- .../SearchResultFormatterSpec.swift | 74 +++++++++-------- .../Network/MockFromFileNetworkSession.swift | 12 ++- .../masTests/Network/MockNetworkSession.swift | 16 +++- .../Network/NetworkManagerTests.swift | 70 +++++----------- 23 files changed, 190 insertions(+), 336 deletions(-) delete mode 100644 Tests/masTests/Extensions/String+FileExtension.swift diff --git a/Sources/mas/AppStore/PurchaseDownloadObserver.swift b/Sources/mas/AppStore/PurchaseDownloadObserver.swift index fa3e9e76..fd71e342 100644 --- a/Sources/mas/AppStore/PurchaseDownloadObserver.swift +++ b/Sources/mas/AppStore/PurchaseDownloadObserver.swift @@ -15,10 +15,10 @@ private let downloadedPhase: Int64 = 5 @objc class PurchaseDownloadObserver: NSObject, CKDownloadQueueObserver { - let purchase: SSPurchase + private let purchase: SSPurchase var completionHandler: (() -> Void)? var errorHandler: ((MASError) -> Void)? - var priorPhaseType: Int64? + private var priorPhaseType: Int64? init(purchase: SSPurchase) { self.purchase = purchase @@ -99,16 +99,8 @@ func progress(_ state: ProgressState) { } let barLength = 60 - let completeLength = Int(state.percentComplete * Float(barLength)) - var bar = "" - for index in 0.. Promise<[SearchResult]> { // Search for apps for compatible platforms, in order of preference. // Macs with Apple Silicon can run iPad and iPhone apps. - var entities = [Entity.desktopSoftware] #if arch(arm64) - entities += [.iPadSoftware, .iPhoneSoftware] + let entities = [Entity.desktopSoftware, .iPadSoftware, .iPhoneSoftware] + #else + let entities = [Entity.desktopSoftware] #endif let results = entities.map { entity in diff --git a/Sources/mas/Controllers/SoftwareMapAppLibrary.swift b/Sources/mas/Controllers/SoftwareMapAppLibrary.swift index 77e47e50..d9fc313a 100644 --- a/Sources/mas/Controllers/SoftwareMapAppLibrary.swift +++ b/Sources/mas/Controllers/SoftwareMapAppLibrary.swift @@ -10,20 +10,17 @@ import CommerceKit import ScriptingBridge /// Utility for managing installed apps. -class SoftwareMapAppLibrary: AppLibrary { - /// CommerceKit's singleton manager of installed software. - private let softwareMap: SoftwareMap - +struct SoftwareMapAppLibrary: AppLibrary { /// Array of installed software products. - lazy var installedApps: [SoftwareProduct] = softwareMap.allSoftwareProducts() - .filter { product in - product.bundlePath.starts(with: "/Applications/") - } + let installedApps: [SoftwareProduct] /// Internal initializer for providing a mock software map. /// - Parameter softwareMap: SoftwareMap to use init(softwareMap: SoftwareMap = CKSoftwareMap.shared()) { - self.softwareMap = softwareMap + installedApps = softwareMap.allSoftwareProducts() + .filter { product in + product.bundlePath.starts(with: "/Applications/") + } } /// Finds an app using a bundle identifier. diff --git a/Sources/mas/Formatters/Utilities.swift b/Sources/mas/Formatters/Utilities.swift index 63e436ce..ab542e19 100644 --- a/Sources/mas/Formatters/Utilities.swift +++ b/Sources/mas/Formatters/Utilities.swift @@ -11,7 +11,7 @@ import Foundation // A collection of output formatting helpers /// Terminal Control Sequence Indicator. -let csi = "\u{001B}[" +private let csi = "\u{001B}[" private var standardError = FileHandle.standardError diff --git a/Sources/mas/Network/NetworkManager.swift b/Sources/mas/Network/NetworkManager.swift index 5f0ab665..09356878 100644 --- a/Sources/mas/Network/NetworkManager.swift +++ b/Sources/mas/Network/NetworkManager.swift @@ -10,7 +10,7 @@ import Foundation import PromiseKit /// Network abstraction. -class NetworkManager { +struct NetworkManager { private let session: NetworkSession /// Designated initializer. diff --git a/Tests/masTests/Commands/HomeSpec.swift b/Tests/masTests/Commands/HomeSpec.swift index c5a04551..63aba535 100644 --- a/Tests/masTests/Commands/HomeSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -13,18 +13,13 @@ import Quick public class HomeSpec: QuickSpec { override public func spec() { - let searcher = MockAppStoreSearcher() - beforeSuite { MAS.initialize() } describe("home command") { - beforeEach { - searcher.reset() - } it("can't find app with unknown ID") { expect { - try MAS.Home.parse(["999"]).run(searcher: searcher) + try MAS.Home.parse(["999"]).run(searcher: MockAppStoreSearcher()) } .to(throwError(MASError.unknownAppID(999))) } diff --git a/Tests/masTests/Commands/InfoSpec.swift b/Tests/masTests/Commands/InfoSpec.swift index 1cad137d..423c1d4d 100644 --- a/Tests/masTests/Commands/InfoSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -14,18 +14,13 @@ import Quick public class InfoSpec: QuickSpec { override public func spec() { - let searcher = MockAppStoreSearcher() - beforeSuite { MAS.initialize() } describe("Info command") { - beforeEach { - searcher.reset() - } it("can't find app with unknown ID") { expect { - try MAS.Info.parse(["999"]).run(searcher: searcher) + try MAS.Info.parse(["999"]).run(searcher: MockAppStoreSearcher()) } .to(throwError(MASError.unknownAppID(999))) } @@ -41,10 +36,10 @@ public class InfoSpec: QuickSpec { trackViewUrl: "https://awesome.app", version: "1.0" ) - searcher.apps[mockResult.trackId] = mockResult expect { try captureStream(stdout) { - try MAS.Info.parse([String(mockResult.trackId)]).run(searcher: searcher) + try MAS.Info.parse([String(mockResult.trackId)]) + .run(searcher: MockAppStoreSearcher([mockResult.trackId: mockResult])) } } == """ diff --git a/Tests/masTests/Commands/OpenSpec.swift b/Tests/masTests/Commands/OpenSpec.swift index c9cf8743..16a46665 100644 --- a/Tests/masTests/Commands/OpenSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -14,18 +14,13 @@ import Quick public class OpenSpec: QuickSpec { override public func spec() { - let searcher = MockAppStoreSearcher() - beforeSuite { MAS.initialize() } describe("open command") { - beforeEach { - searcher.reset() - } it("can't find app with unknown ID") { expect { - try MAS.Open.parse(["999"]).run(searcher: searcher) + try MAS.Open.parse(["999"]).run(searcher: MockAppStoreSearcher()) } .to(throwError(MASError.unknownAppID(999))) } diff --git a/Tests/masTests/Commands/OutdatedSpec.swift b/Tests/masTests/Commands/OutdatedSpec.swift index 95ced8e0..3d50d9b2 100644 --- a/Tests/masTests/Commands/OutdatedSpec.swift +++ b/Tests/masTests/Commands/OutdatedSpec.swift @@ -33,22 +33,22 @@ public class OutdatedSpec: QuickSpec { trackViewUrl: "https://apps.apple.com/us/app/bandwidth/id490461369?mt=12&uo=4", version: "1.28" ) - let searcher = MockAppStoreSearcher() - searcher.apps[mockSearchResult.trackId] = mockSearchResult - let mockAppLibrary = MockAppLibrary() - mockAppLibrary.installedApps.append( - MockSoftwareProduct( - appName: mockSearchResult.trackName, - bundleIdentifier: mockSearchResult.bundleId, - bundlePath: "/Applications/Bandwidth+.app", - bundleVersion: "1.27", - itemIdentifier: NSNumber(value: mockSearchResult.trackId) - ) - ) expect { try captureStream(stdout) { - try MAS.Outdated.parse([]).run(appLibrary: mockAppLibrary, searcher: searcher) + try MAS.Outdated.parse([]) + .run( + appLibrary: MockAppLibrary( + MockSoftwareProduct( + appName: mockSearchResult.trackName, + bundleIdentifier: mockSearchResult.bundleId, + bundlePath: "/Applications/Bandwidth+.app", + bundleVersion: "1.27", + itemIdentifier: NSNumber(value: mockSearchResult.trackId) + ) + ), + searcher: MockAppStoreSearcher([mockSearchResult.trackId: mockSearchResult]) + ) } } == "490461369 Bandwidth+ (1.27 -> 1.28)\n" diff --git a/Tests/masTests/Commands/SearchSpec.swift b/Tests/masTests/Commands/SearchSpec.swift index 183e79fa..afc6637a 100644 --- a/Tests/masTests/Commands/SearchSpec.swift +++ b/Tests/masTests/Commands/SearchSpec.swift @@ -14,15 +14,10 @@ import Quick public class SearchSpec: QuickSpec { override public func spec() { - let searcher = MockAppStoreSearcher() - beforeSuite { MAS.initialize() } describe("search command") { - beforeEach { - searcher.reset() - } it("can find slack") { let mockResult = SearchResult( trackId: 1111, @@ -30,17 +25,17 @@ public class SearchSpec: QuickSpec { trackViewUrl: "mas preview url", version: "0.0" ) - searcher.apps[mockResult.trackId] = mockResult expect { try captureStream(stdout) { - try MAS.Search.parse(["slack"]).run(searcher: searcher) + try MAS.Search.parse(["slack"]) + .run(searcher: MockAppStoreSearcher([mockResult.trackId: mockResult])) } } == " 1111 slack (0.0)\n" } it("fails when searching for nonexistent app") { expect { - try MAS.Search.parse(["nonexistent"]).run(searcher: searcher) + try MAS.Search.parse(["nonexistent"]).run(searcher: MockAppStoreSearcher()) } .to(throwError(MASError.noSearchResultsFound)) } diff --git a/Tests/masTests/Commands/UninstallSpec.swift b/Tests/masTests/Commands/UninstallSpec.swift index 7ea7c441..24de94da 100644 --- a/Tests/masTests/Commands/UninstallSpec.swift +++ b/Tests/masTests/Commands/UninstallSpec.swift @@ -26,25 +26,20 @@ public class UninstallSpec: QuickSpec { bundleVersion: "1.0", itemIdentifier: NSNumber(value: appID) ) - let mockLibrary = MockAppLibrary() context("dry run") { let uninstall = try! MAS.Uninstall.parse(["--dry-run", String(appID)]) - beforeEach { - mockLibrary.reset() - } it("can't remove a missing app") { expect { - try uninstall.run(appLibrary: mockLibrary) + try uninstall.run(appLibrary: MockAppLibrary()) } .to(throwError(MASError.notInstalled(appID: appID))) } it("finds an app") { - mockLibrary.installedApps.append(app) expect { try captureStream(stdout) { - try uninstall.run(appLibrary: mockLibrary) + try uninstall.run(appLibrary: MockAppLibrary(app)) } } == "==> 'Some App' '/tmp/Some.app'\n==> (not removed, dry run)\n" @@ -53,20 +48,16 @@ public class UninstallSpec: QuickSpec { context("wet run") { let uninstall = try! MAS.Uninstall.parse([String(appID)]) - beforeEach { - mockLibrary.reset() - } it("can't remove a missing app") { expect { - try uninstall.run(appLibrary: mockLibrary) + try uninstall.run(appLibrary: MockAppLibrary()) } .to(throwError(MASError.notInstalled(appID: appID))) } it("removes an app") { - mockLibrary.installedApps.append(app) expect { try captureStream(stdout) { - try uninstall.run(appLibrary: mockLibrary) + try uninstall.run(appLibrary: MockAppLibrary(app)) } } .toNot(throwError()) @@ -74,10 +65,9 @@ public class UninstallSpec: QuickSpec { it("fails if there is a problem with the trash command") { var brokenApp = app brokenApp.bundlePath = "/dev/null" - mockLibrary.installedApps.append(brokenApp) expect { try captureStream(stdout) { - try uninstall.run(appLibrary: mockLibrary) + try uninstall.run(appLibrary: MockAppLibrary(brokenApp)) } } .to(throwError(MASError.uninstallFailed(error: nil))) diff --git a/Tests/masTests/Commands/VendorSpec.swift b/Tests/masTests/Commands/VendorSpec.swift index 7eb0f580..753b86cc 100644 --- a/Tests/masTests/Commands/VendorSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -13,18 +13,13 @@ import Quick public class VendorSpec: QuickSpec { override public func spec() { - let searcher = MockAppStoreSearcher() - beforeSuite { MAS.initialize() } describe("vendor command") { - beforeEach { - searcher.reset() - } it("can't find app with unknown ID") { expect { - try MAS.Vendor.parse(["999"]).run(searcher: searcher) + try MAS.Vendor.parse(["999"]).run(searcher: MockAppStoreSearcher()) } .to(throwError(MASError.unknownAppID(999))) } diff --git a/Tests/masTests/Controllers/MockAppLibrary.swift b/Tests/masTests/Controllers/MockAppLibrary.swift index c992abc8..23bffcdf 100644 --- a/Tests/masTests/Controllers/MockAppLibrary.swift +++ b/Tests/masTests/Controllers/MockAppLibrary.swift @@ -8,8 +8,12 @@ @testable import mas -class MockAppLibrary: AppLibrary { - var installedApps: [SoftwareProduct] = [] +struct MockAppLibrary: AppLibrary { + let installedApps: [SoftwareProduct] + + init(_ installedApps: SoftwareProduct...) { + self.installedApps = installedApps + } func uninstallApps(atPaths appPaths: [String]) throws { // Special case for testing where we pretend the trash command failed @@ -18,11 +22,3 @@ class MockAppLibrary: AppLibrary { } } } - -/// Members not part of the AppLibrary protocol that are only for test state management. -extension MockAppLibrary { - /// Clears out the list of installed apps. - func reset() { - installedApps = [] - } -} diff --git a/Tests/masTests/Controllers/MockAppStoreSearcher.swift b/Tests/masTests/Controllers/MockAppStoreSearcher.swift index c2eb5fe1..a39511f6 100644 --- a/Tests/masTests/Controllers/MockAppStoreSearcher.swift +++ b/Tests/masTests/Controllers/MockAppStoreSearcher.swift @@ -10,8 +10,12 @@ import PromiseKit @testable import mas -class MockAppStoreSearcher: AppStoreSearcher { - var apps: [AppID: SearchResult] = [:] +struct MockAppStoreSearcher: AppStoreSearcher { + let apps: [AppID: SearchResult] + + init(_ apps: [AppID: SearchResult] = [:]) { + self.apps = apps + } func search(for searchTerm: String) -> Promise<[SearchResult]> { .value(apps.filter { $1.trackName.contains(searchTerm) }.map { $1 }) @@ -24,8 +28,4 @@ class MockAppStoreSearcher: AppStoreSearcher { return .value(result) } - - func reset() { - apps = [:] - } } diff --git a/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift b/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift index c4e50cab..2f32eaef 100644 --- a/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift +++ b/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift @@ -13,6 +13,16 @@ import Quick public class SoftwareMapAppLibrarySpec: QuickSpec { override public func spec() { + let myApp = MockSoftwareProduct( + appName: "MyApp", + bundleIdentifier: "com.example", + bundlePath: "/Applications/MyApp.app", + bundleVersion: "1.0.0", + itemIdentifier: 1234 + ) + + let apps = [myApp] + let library = SoftwareMapAppLibrary(softwareMap: MockSoftwareMap(products: apps)) beforeSuite { @@ -30,20 +40,9 @@ public class SoftwareMapAppLibrarySpec: QuickSpec { } } -// MARK: - Test Data -let myApp = MockSoftwareProduct( - appName: "MyApp", - bundleIdentifier: "com.example", - bundlePath: "/Applications/MyApp.app", - bundleVersion: "1.0.0", - itemIdentifier: 1234 -) - -var apps: [SoftwareProduct] = [myApp] - // MARK: - MockSoftwareMap struct MockSoftwareMap: SoftwareMap { - var products: [SoftwareProduct] = [] + let products: [SoftwareProduct] func allSoftwareProducts() -> [SoftwareProduct] { products diff --git a/Tests/masTests/Errors/MASErrorTestCase.swift b/Tests/masTests/Errors/MASErrorTestCase.swift index 3a115b3a..a1a91801 100644 --- a/Tests/masTests/Errors/MASErrorTestCase.swift +++ b/Tests/masTests/Errors/MASErrorTestCase.swift @@ -12,119 +12,81 @@ import XCTest @testable import mas class MASErrorTestCase: XCTestCase { - private let errorDomain = "MAS" - private var error: MASError! - private var nserror: NSError! - - /// Convenience property for setting the value which will be use for the localized description - /// value of the next NSError created. - /// - /// Only used when the NSError does not have a user info - /// entry for localized description. - private var localizedDescription: String { - get { "dummy value" } - set { - NSError.setUserInfoValueProvider(forDomain: errorDomain) { _, _ in - newValue - } - } - } + private static let error = NSError(domain: "MAS", code: 999, userInfo: [NSLocalizedDescriptionKey: "foo"]) override func setUp() { super.setUp() MAS.initialize() - nserror = NSError(domain: errorDomain, code: 999) - localizedDescription = "foo" - } - - override func tearDown() { - nserror = nil - error = nil - super.tearDown() } func testNotSignedIn() { - error = .notSignedIn - XCTAssertEqual(error.description, "Not signed in") + XCTAssertEqual(MASError.notSignedIn.description, "Not signed in") } func testSignInFailed() { - error = .signInFailed(error: nil) - XCTAssertEqual(error.description, "Sign in failed") + XCTAssertEqual(MASError.signInFailed(error: nil).description, "Sign in failed") } func testSignInFailedError() { - error = .signInFailed(error: nserror) - XCTAssertEqual(error.description, "Sign in failed: foo") + XCTAssertEqual(MASError.signInFailed(error: Self.error).description, "Sign in failed: foo") } func testAlreadySignedIn() { - error = .alreadySignedIn(asAppleID: "person@example.com") - XCTAssertEqual(error.description, "Already signed in as person@example.com") + XCTAssertEqual( + MASError.alreadySignedIn(asAppleID: "person@example.com").description, + "Already signed in as person@example.com" + ) } func testPurchaseFailed() { - error = .purchaseFailed(error: nil) - XCTAssertEqual(error.description, "Download request failed") + XCTAssertEqual(MASError.purchaseFailed(error: nil).description, "Download request failed") } func testPurchaseFailedError() { - error = .purchaseFailed(error: nserror) - XCTAssertEqual(error.description, "Download request failed: foo") + XCTAssertEqual(MASError.purchaseFailed(error: Self.error).description, "Download request failed: foo") } func testDownloadFailed() { - error = .downloadFailed(error: nil) - XCTAssertEqual(error.description, "Download failed") + XCTAssertEqual(MASError.downloadFailed(error: nil).description, "Download failed") } func testDownloadFailedError() { - error = .downloadFailed(error: nserror) - XCTAssertEqual(error.description, "Download failed: foo") + XCTAssertEqual(MASError.downloadFailed(error: Self.error).description, "Download failed: foo") } func testNoDownloads() { - error = .noDownloads - XCTAssertEqual(error.description, "No downloads began") + XCTAssertEqual(MASError.noDownloads.description, "No downloads began") } func testCancelled() { - error = .cancelled - XCTAssertEqual(error.description, "Download cancelled") + XCTAssertEqual(MASError.cancelled.description, "Download cancelled") } func testSearchFailed() { - error = .searchFailed - XCTAssertEqual(error.description, "Search failed") + XCTAssertEqual(MASError.searchFailed.description, "Search failed") } func testNoSearchResultsFound() { - error = .noSearchResultsFound - XCTAssertEqual(error.description, "No apps found") + XCTAssertEqual(MASError.noSearchResultsFound.description, "No apps found") } func testNoVendorWebsite() { - error = .noVendorWebsite - XCTAssertEqual(error.description, "App does not have a vendor website") + XCTAssertEqual(MASError.noVendorWebsite.description, "App does not have a vendor website") } func testNotInstalled() { - error = .notInstalled(appID: 123) - XCTAssertEqual(error.description, "No apps installed with app ID 123") + XCTAssertEqual(MASError.notInstalled(appID: 123).description, "No apps installed with app ID 123") } func testUninstallFailed() { - error = .uninstallFailed(error: nil) - XCTAssertEqual(error.description, "Uninstall failed") + XCTAssertEqual(MASError.uninstallFailed(error: nil).description, "Uninstall failed") } func testNoData() { - error = .noData - XCTAssertEqual(error.description, "Service did not return data") + XCTAssertEqual(MASError.noData.description, "Service did not return data") } func testJsonParsing() { - error = .jsonParsing(data: nil) - XCTAssertEqual(error.description, "Received empty response") + XCTAssertEqual(MASError.jsonParsing(data: nil).description, "Received empty response") } } diff --git a/Tests/masTests/Extensions/Bundle+JSON.swift b/Tests/masTests/Extensions/Bundle+JSON.swift index 19c2c0d7..5efcbf50 100644 --- a/Tests/masTests/Extensions/Bundle+JSON.swift +++ b/Tests/masTests/Extensions/Bundle+JSON.swift @@ -13,8 +13,7 @@ extension Data { /// /// - Parameter fileName: Relative path within the JSON folder init(from fileName: String) { - let fileURL = Bundle.url(for: fileName)! - try! self.init(contentsOf: fileURL, options: .mappedIfSafe) + try! self.init(contentsOf: Bundle.url(for: fileName), options: .mappedIfSafe) } } @@ -23,32 +22,11 @@ extension Bundle { /// /// - Parameter fileName: Name of file to locate. /// - Returns: URL to file. - static func url(for fileName: String) -> URL? { - // The Swift Package Manager places resources in a separate bundle from the executable. - // https://forums.swift.org/t/swift-5-3-spm-resources-in-tests-uses-wrong-bundle-path/37051 - let bundleURL = Bundle(for: MockNetworkSession.self) - .bundleURL - .deletingLastPathComponent() - .appendingPathComponent("mas_masTests.bundle") - guard - let bundle = Bundle(url: bundleURL), - let url = bundle.url(for: fileName) - else { + static func url(for fileName: String) -> URL { + guard let url = Bundle.module.url(forResource: fileName, withExtension: nil, subdirectory: "JSON") else { fatalError("Unable to load file \(fileName)") } return url } - - /// Builds a URL for a file in the JSON directory of the current bundle. - /// - /// - Parameter fileName: Name of file to locate. - /// - Returns: URL to file. - private func url(for fileName: String) -> URL? { - url( - forResource: fileName.fileNameWithoutExtension, - withExtension: fileName.fileExtension, - subdirectory: "JSON" - ) - } } diff --git a/Tests/masTests/Extensions/String+FileExtension.swift b/Tests/masTests/Extensions/String+FileExtension.swift deleted file mode 100644 index 6f8649ae..00000000 --- a/Tests/masTests/Extensions/String+FileExtension.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// String+FileExtension.swift -// masTests -// -// Created by Ben Chatelain on 1/5/19. -// Copyright © 2019 mas-cli. All rights reserved. -// - -import Foundation - -extension String { - /// Returns the file name before the extension. - var fileNameWithoutExtension: String { - (self as NSString).deletingPathExtension - } - - /// Returns the file extension. - var fileExtension: String { - (self as NSString).pathExtension - } -} diff --git a/Tests/masTests/Formatters/AppListFormatterSpec.swift b/Tests/masTests/Formatters/AppListFormatterSpec.swift index 1b923977..a44730ca 100644 --- a/Tests/masTests/Formatters/AppListFormatterSpec.swift +++ b/Tests/masTests/Formatters/AppListFormatterSpec.swift @@ -15,17 +15,13 @@ public class AppListFormatterSpec: QuickSpec { override public func spec() { // static func reference let format = AppListFormatter.format(products:) - var products: [SoftwareProduct] = [] beforeSuite { MAS.initialize() } describe("app list formatter") { - beforeEach { - products = [] - } it("formats nothing as empty string") { - expect(format(products)).to(beEmpty()) + expect(format([])).to(beEmpty()) } it("can format a single product") { let product = MockSoftwareProduct( @@ -38,23 +34,26 @@ public class AppListFormatterSpec: QuickSpec { expect(format([product])) == "12345 Awesome App (19.2.1)" } it("can format two products") { - products = [ - MockSoftwareProduct( - appName: "Awesome App", - bundleIdentifier: "", - bundlePath: "", - bundleVersion: "19.2.1", - itemIdentifier: 12345 - ), - MockSoftwareProduct( - appName: "Even Better App", - bundleIdentifier: "", - bundlePath: "", - bundleVersion: "1.2.0", - itemIdentifier: 67890 - ), - ] - expect(format(products)) + expect( + format( + [ + MockSoftwareProduct( + appName: "Awesome App", + bundleIdentifier: "", + bundlePath: "", + bundleVersion: "19.2.1", + itemIdentifier: 12345 + ), + MockSoftwareProduct( + appName: "Even Better App", + bundleIdentifier: "", + bundlePath: "", + bundleVersion: "1.2.0", + itemIdentifier: 67890 + ), + ] + ) + ) == "12345 Awesome App (19.2.1)\n67890 Even Better App (1.2.0)" } } diff --git a/Tests/masTests/Formatters/SearchResultFormatterSpec.swift b/Tests/masTests/Formatters/SearchResultFormatterSpec.swift index 2db3e52c..bff6201e 100644 --- a/Tests/masTests/Formatters/SearchResultFormatterSpec.swift +++ b/Tests/masTests/Formatters/SearchResultFormatterSpec.swift @@ -15,17 +15,13 @@ public class SearchResultFormatterSpec: QuickSpec { override public func spec() { // static func reference let format = SearchResultFormatter.format(results:includePrice:) - var results: [SearchResult] = [] beforeSuite { MAS.initialize() } describe("search results formatter") { - beforeEach { - results = [] - } it("formats nothing as empty string") { - expect(format(results, false)).to(beEmpty()) + expect(format([], false)).to(beEmpty()) } it("can format a single result") { let result = SearchResult( @@ -46,39 +42,47 @@ public class SearchResultFormatterSpec: QuickSpec { expect(format([result], true)) == " 12345 Awesome App (19.2.1) $9.87" } it("can format a two results") { - results = [ - SearchResult( - formattedPrice: "$9.87", - trackId: 12345, - trackName: "Awesome App", - version: "19.2.1" - ), - SearchResult( - formattedPrice: "$0.01", - trackId: 67890, - trackName: "Even Better App", - version: "1.2.0" - ), - ] - expect(format(results, false)) + expect( + format( + [ + SearchResult( + formattedPrice: "$9.87", + trackId: 12345, + trackName: "Awesome App", + version: "19.2.1" + ), + SearchResult( + formattedPrice: "$0.01", + trackId: 67890, + trackName: "Even Better App", + version: "1.2.0" + ), + ], + false + ) + ) == " 12345 Awesome App (19.2.1)\n 67890 Even Better App (1.2.0)" } it("can format a two results with prices") { - results = [ - SearchResult( - formattedPrice: "$9.87", - trackId: 12345, - trackName: "Awesome App", - version: "19.2.1" - ), - SearchResult( - formattedPrice: "$0.01", - trackId: 67890, - trackName: "Even Better App", - version: "1.2.0" - ), - ] - expect(format(results, true)) + expect( + format( + [ + SearchResult( + formattedPrice: "$9.87", + trackId: 12345, + trackName: "Awesome App", + version: "19.2.1" + ), + SearchResult( + formattedPrice: "$0.01", + trackId: 67890, + trackName: "Even Better App", + version: "1.2.0" + ), + ], + true + ) + ) == " 12345 Awesome App (19.2.1) $9.87\n 67890 Even Better App (1.2.0) $0.01" } } diff --git a/Tests/masTests/Network/MockFromFileNetworkSession.swift b/Tests/masTests/Network/MockFromFileNetworkSession.swift index 893f15e3..2deee739 100644 --- a/Tests/masTests/Network/MockFromFileNetworkSession.swift +++ b/Tests/masTests/Network/MockFromFileNetworkSession.swift @@ -9,8 +9,10 @@ import Foundation import PromiseKit +@testable import mas + /// Mock NetworkSession for testing with saved JSON response payload files. -class MockFromFileNetworkSession: MockNetworkSession { +struct MockFromFileNetworkSession: NetworkSession { /// Path to response payload file relative to test bundle. private let responseFile: String @@ -21,13 +23,9 @@ class MockFromFileNetworkSession: MockNetworkSession { self.responseFile = responseFile } - override func loadData(from _: URL) -> Promise { - guard let fileURL = Bundle.url(for: responseFile) else { - fatalError("Unable to load file \(responseFile)") - } - + func loadData(from _: URL) -> Promise { do { - return .value(try Data(contentsOf: fileURL, options: .mappedIfSafe)) + return .value(try Data(contentsOf: Bundle.url(for: responseFile), options: .mappedIfSafe)) } catch { print("Error opening file: \(error)") return Promise(error: error) diff --git a/Tests/masTests/Network/MockNetworkSession.swift b/Tests/masTests/Network/MockNetworkSession.swift index 504ff328..3bd3b82c 100644 --- a/Tests/masTests/Network/MockNetworkSession.swift +++ b/Tests/masTests/Network/MockNetworkSession.swift @@ -12,11 +12,21 @@ import PromiseKit @testable import mas /// Mock NetworkSession for testing. -class MockNetworkSession: NetworkSession { +struct MockNetworkSession: NetworkSession { // Properties that enable us to set exactly what data or error // we want our mocked URLSession to return for any request. - var data: Data? - var error: Error? + private let data: Data? + private let error: Error? + + init(data: Data) { + self.data = data + error = nil + } + + init(error: Error) { + self.error = error + data = nil + } func loadData(from _: URL) -> Promise { guard let data else { diff --git a/Tests/masTests/Network/NetworkManagerTests.swift b/Tests/masTests/Network/NetworkManagerTests.swift index 6f3e5209..560f7ce9 100644 --- a/Tests/masTests/Network/NetworkManagerTests.swift +++ b/Tests/masTests/Network/NetworkManagerTests.swift @@ -17,51 +17,31 @@ class NetworkManagerTests: XCTestCase { } func testSuccessfulAsyncResponse() throws { - // Setup our objects - let session = MockNetworkSession() - let manager = NetworkManager(session: session) - - // Create data and tell the session to always return it let data = Data([0, 1, 0, 1]) - session.data = data - - // Create a URL (using the file path API to avoid optionals) - let url = URL(fileURLWithPath: "url") - - // Perform the request and verify the result - let response = try manager.loadData(from: url).wait() - XCTAssertEqual(response, data) + XCTAssertEqual( + try NetworkManager(session: MockNetworkSession(data: data)) + .loadData(from: URL(fileURLWithPath: "url")) + .wait(), + data + ) } func testSuccessfulSyncResponse() throws { - // Setup our objects - let session = MockNetworkSession() - let manager = NetworkManager(session: session) - - // Create data and tell the session to always return it let data = Data([0, 1, 0, 1]) - session.data = data - - // Create a URL (using the file path API to avoid optionals) - let url = URL(fileURLWithPath: "url") - - // Perform the request and verify the result - let result = try manager.loadData(from: url).wait() - XCTAssertEqual(result, data) + XCTAssertEqual( + try NetworkManager(session: MockNetworkSession(data: data)) + .loadData(from: URL(fileURLWithPath: "url")) + .wait(), + data + ) } func testFailureAsyncResponse() { - // Setup our objects - let session = MockNetworkSession() - let manager = NetworkManager(session: session) - - session.error = MASError.noData - - // Create a URL (using the file path API to avoid optionals) - let url = URL(fileURLWithPath: "url") - - // Perform the request and verify the result - XCTAssertThrowsError(try manager.loadData(from: url).wait()) { error in + XCTAssertThrowsError( + try NetworkManager(session: MockNetworkSession(error: MASError.noData)) + .loadData(from: URL(fileURLWithPath: "url")) + .wait() + ) { error in guard let masError = error as? MASError else { XCTFail("Error is of unexpected type.") return @@ -72,17 +52,11 @@ class NetworkManagerTests: XCTestCase { } func testFailureSyncResponse() { - // Setup our objects - let session = MockNetworkSession() - let manager = NetworkManager(session: session) - - session.error = MASError.noData - - // Create a URL (using the file path API to avoid optionals) - let url = URL(fileURLWithPath: "url") - - // Perform the request and verify the result - XCTAssertThrowsError(try manager.loadData(from: url).wait()) { error in + XCTAssertThrowsError( + try NetworkManager(session: MockNetworkSession(error: MASError.noData)) + .loadData(from: URL(fileURLWithPath: "url")) + .wait() + ) { error in guard let error = error as? MASError else { XCTFail("Error is of unexpected type.") return From 5d92266600cab625f957682887d18bbc3c08a1eb Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 29 Dec 2024 18:43:17 -0500 Subject: [PATCH 2/4] Remove `bundleIdentifier` related functions. Partial #686 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/AppStore/CKSoftwareMap+SoftwareMap.swift | 5 ----- Sources/mas/Controllers/SoftwareMap.swift | 1 - Sources/mas/Controllers/SoftwareMapAppLibrary.swift | 8 -------- .../Controllers/SoftwareMapAppLibrarySpec.swift | 10 ---------- 4 files changed, 24 deletions(-) diff --git a/Sources/mas/AppStore/CKSoftwareMap+SoftwareMap.swift b/Sources/mas/AppStore/CKSoftwareMap+SoftwareMap.swift index dde45352..a873a080 100644 --- a/Sources/mas/AppStore/CKSoftwareMap+SoftwareMap.swift +++ b/Sources/mas/AppStore/CKSoftwareMap+SoftwareMap.swift @@ -8,13 +8,8 @@ import CommerceKit -// MARK: - SoftwareProduct extension CKSoftwareMap: SoftwareMap { func allSoftwareProducts() -> [SoftwareProduct] { allProducts() ?? [] } - - func product(for bundleIdentifier: String) -> SoftwareProduct? { - product(forBundleIdentifier: bundleIdentifier) - } } diff --git a/Sources/mas/Controllers/SoftwareMap.swift b/Sources/mas/Controllers/SoftwareMap.swift index b131d422..94e03a62 100644 --- a/Sources/mas/Controllers/SoftwareMap.swift +++ b/Sources/mas/Controllers/SoftwareMap.swift @@ -9,5 +9,4 @@ /// Somewhat analogous to CKSoftwareMap. protocol SoftwareMap { func allSoftwareProducts() -> [SoftwareProduct] - func product(for bundleIdentifier: String) -> SoftwareProduct? } diff --git a/Sources/mas/Controllers/SoftwareMapAppLibrary.swift b/Sources/mas/Controllers/SoftwareMapAppLibrary.swift index d9fc313a..f6f33cce 100644 --- a/Sources/mas/Controllers/SoftwareMapAppLibrary.swift +++ b/Sources/mas/Controllers/SoftwareMapAppLibrary.swift @@ -23,14 +23,6 @@ struct SoftwareMapAppLibrary: AppLibrary { } } - /// Finds an app using a bundle identifier. - /// - /// - Parameter bundleID: Bundle identifier of app. - /// - Returns: `SoftwareProduct` for app if found; `nil` otherwise. - func installedApp(forBundleID bundleID: String) -> SoftwareProduct? { - softwareMap.product(for: bundleID) - } - /// Uninstalls all apps located at any of the elements of `appPaths`. /// /// - Parameter appPaths: Paths to apps to be uninstalled. diff --git a/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift b/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift index 2f32eaef..a2e1f6a5 100644 --- a/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift +++ b/Tests/masTests/Controllers/SoftwareMapAppLibrarySpec.swift @@ -33,9 +33,6 @@ public class SoftwareMapAppLibrarySpec: QuickSpec { expect(library.installedApps).to(haveCount(apps.count)) expect(library.installedApps.first!.appName) == myApp.appName } - it("can locate an app by bundle id") { - expect(library.installedApp(forBundleID: "com.example")!.bundleIdentifier) == myApp.bundleIdentifier - } } } } @@ -47,11 +44,4 @@ struct MockSoftwareMap: SoftwareMap { func allSoftwareProducts() -> [SoftwareProduct] { products } - - func product(for bundleIdentifier: String) -> SoftwareProduct? { - for product in products where product.bundleIdentifier == bundleIdentifier { - return product - } - return nil - } } From e6f28eb49e9b9663b02d4c7967d31f1b43ed3f9f Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 29 Dec 2024 19:13:53 -0500 Subject: [PATCH 3/4] Simplify `SearchResult.swift`. Partial #686 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Models/SearchResult.swift | 50 ++++--------------- Tests/masTests/Commands/OutdatedSpec.swift | 4 +- .../ITunesSearchAppStoreSearcherSpec.swift | 2 - Tests/masTests/Models/SearchResultSpec.swift | 4 +- 4 files changed, 13 insertions(+), 47 deletions(-) diff --git a/Sources/mas/Models/SearchResult.swift b/Sources/mas/Models/SearchResult.swift index 6c479c3e..6eb8bce3 100644 --- a/Sources/mas/Models/SearchResult.swift +++ b/Sources/mas/Models/SearchResult.swift @@ -7,48 +7,18 @@ // struct SearchResult: Decodable { - var bundleId: String - var currentVersionReleaseDate: String - var fileSizeBytes: String - var formattedPrice: String? - var minimumOsVersion: String - var price: Double? - var sellerName: String - var sellerUrl: String? - var trackId: AppID - var trackName: String - var trackViewUrl: String - var version: String + var currentVersionReleaseDate = "" + var fileSizeBytes = "0" + var formattedPrice: String? = "0" + var minimumOsVersion = "" + var sellerName = "" + var sellerUrl: String? = "" + var trackId: AppID = 0 + var trackName = "" + var trackViewUrl = "" + var version = "" var displayPrice: String { formattedPrice ?? "Unknown" } - - init( - bundleId: String = "", - currentVersionReleaseDate: String = "", - fileSizeBytes: String = "0", - formattedPrice: String = "0", - minimumOsVersion: String = "", - price: Double = 0.0, - sellerName: String = "", - sellerUrl: String = "", - trackId: AppID = 0, - trackName: String = "", - trackViewUrl: String = "", - version: String = "" - ) { - self.bundleId = bundleId - self.currentVersionReleaseDate = currentVersionReleaseDate - self.fileSizeBytes = fileSizeBytes - self.formattedPrice = formattedPrice - self.minimumOsVersion = minimumOsVersion - self.price = price - self.sellerName = sellerName - self.sellerUrl = sellerUrl - self.trackId = trackId - self.trackName = trackName - self.trackViewUrl = trackViewUrl - self.version = version - } } diff --git a/Tests/masTests/Commands/OutdatedSpec.swift b/Tests/masTests/Commands/OutdatedSpec.swift index 3d50d9b2..ff696a8b 100644 --- a/Tests/masTests/Commands/OutdatedSpec.swift +++ b/Tests/masTests/Commands/OutdatedSpec.swift @@ -21,11 +21,9 @@ public class OutdatedSpec: QuickSpec { it("displays apps with pending updates") { let mockSearchResult = SearchResult( - bundleId: "au.id.haroldchu.mac.Bandwidth", currentVersionReleaseDate: "2024-09-02T00:27:00Z", fileSizeBytes: "998130", minimumOsVersion: "10.13", - price: 0, sellerName: "Harold Chu", sellerUrl: "https://example.com", trackId: 490_461_369, @@ -41,7 +39,7 @@ public class OutdatedSpec: QuickSpec { appLibrary: MockAppLibrary( MockSoftwareProduct( appName: mockSearchResult.trackName, - bundleIdentifier: mockSearchResult.bundleId, + bundleIdentifier: "au.id.haroldchu.mac.Bandwidth", bundlePath: "/Applications/Bandwidth+.app", bundleVersion: "1.27", itemIdentifier: NSNumber(value: mockSearchResult.trackId) diff --git a/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift b/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift index 828ff31a..0654badb 100644 --- a/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift +++ b/Tests/masTests/Controllers/ITunesSearchAppStoreSearcherSpec.swift @@ -64,8 +64,6 @@ public class ITunesSearchAppStoreSearcherSpec: QuickSpec { } expect(result.trackId) == appID - expect(result.bundleId) == "com.tinyspeck.slackmacgap" - expect(result.price) == 0 expect(result.sellerName) == "Slack Technologies, Inc." expect(result.sellerUrl) == "https://slack.com" expect(result.trackName) == "Slack" diff --git a/Tests/masTests/Models/SearchResultSpec.swift b/Tests/masTests/Models/SearchResultSpec.swift index 9462456d..50436ceb 100644 --- a/Tests/masTests/Models/SearchResultSpec.swift +++ b/Tests/masTests/Models/SearchResultSpec.swift @@ -22,9 +22,9 @@ public class SearchResultSpec: QuickSpec { expect( try JSONDecoder() .decode(SearchResult.self, from: Data(from: "search/things-that-go-bump.json")) - .bundleId + .trackId ) - == "uikitformac.com.tinybop.thingamabops" + == 1_472_954_003 } } } From 75d651131cea1314fcb158c6af685e59ef1501f1 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Sun, 29 Dec 2024 19:18:54 -0500 Subject: [PATCH 4/4] =?UTF-8?q?Move=20`captureStream(=E2=80=A6)`=20from=20?= =?UTF-8?q?`mas`=20to=20`masTests`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve #686 Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/mas/Formatters/Utilities.swift | 27 ------------------- Tests/masTests/Utilities/Streams.swift | 36 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 27 deletions(-) create mode 100644 Tests/masTests/Utilities/Streams.swift diff --git a/Sources/mas/Formatters/Utilities.swift b/Sources/mas/Formatters/Utilities.swift index ab542e19..acef89a6 100644 --- a/Sources/mas/Formatters/Utilities.swift +++ b/Sources/mas/Formatters/Utilities.swift @@ -67,30 +67,3 @@ func clearLine() { print("\(csi)2K\(csi)0G", terminator: "") fflush(stdout) } - -func captureStream( - _ stream: UnsafeMutablePointer, - encoding: String.Encoding = .utf8, - _ block: @escaping () throws -> Void -) rethrows -> String { - let originalFd = fileno(stream) - let duplicateFd = dup(originalFd) - defer { - close(duplicateFd) - } - - let pipe = Pipe() - dup2(pipe.fileHandleForWriting.fileDescriptor, originalFd) - - do { - defer { - fflush(stream) - dup2(duplicateFd, originalFd) - pipe.fileHandleForWriting.closeFile() - } - - try block() - } - - return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: encoding) ?? "" -} diff --git a/Tests/masTests/Utilities/Streams.swift b/Tests/masTests/Utilities/Streams.swift new file mode 100644 index 00000000..14899f26 --- /dev/null +++ b/Tests/masTests/Utilities/Streams.swift @@ -0,0 +1,36 @@ +// +// Streams.swift +// masTests +// +// Created by Ross Goldberg on 2024-12-29. +// Copyright © 2016 mas-cli. All rights reserved. +// + +import Foundation + +func captureStream( + _ stream: UnsafeMutablePointer, + encoding: String.Encoding = .utf8, + _ block: @escaping () throws -> Void +) rethrows -> String { + let originalFd = fileno(stream) + let duplicateFd = dup(originalFd) + defer { + close(duplicateFd) + } + + let pipe = Pipe() + dup2(pipe.fileHandleForWriting.fileDescriptor, originalFd) + + do { + defer { + fflush(stream) + dup2(duplicateFd, originalFd) + pipe.fileHandleForWriting.closeFile() + } + + try block() + } + + return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: encoding) ?? "" +}