♻️🤡 Move trash method to AppLibrary, mock for tests

This commit is contained in:
Ben Chatelain 2018-12-27 16:07:35 -07:00
parent 5be95132e9
commit 97646f579f
7 changed files with 152 additions and 56 deletions

View file

@ -13,4 +13,10 @@ public protocol AppLibrary {
/// - Parameter appId: MAS ID for app.
/// - Returns: Software Product of app if found; nil otherwise.
func installedApp(appId: UInt64) -> SoftwareProduct?
/// Uninstalls an app.
///
/// - Parameter app: App to be removed.
/// - Throws: Error if there is a problem.
func uninstallApp(app: SoftwareProduct) throws
}

View file

@ -11,9 +11,7 @@ import Result
import CommerceKit
import StoreFoundation
/// Command which uninstalls apps managed by the Mac App Store. Relies on the "trash" command from Homebrew.
/// Trash requires el_capitan or higher for core bottles:
/// https://github.com/Homebrew/homebrew-core/blob/master/Formula/trash.rb
/// Command which uninstalls apps managed by the Mac App Store.
public struct UninstallCommand: CommandProtocol {
public typealias Options = UninstallOptions
public let verb = "uninstall"
@ -46,52 +44,14 @@ public struct UninstallCommand: CommandProtocol {
return .success(())
}
// Use the bundle path to delete the app
let status = trash(path: product.bundlePath)
if status {
return .success(())
} else {
return .failure(.searchFailed)
do {
try appLibrary.uninstallApp(app: product)
}
}
/// Runs the trash command in another process.
///
/// - Parameter path: Absolute path to the application bundle to uninstall.
/// - Returns: true on success; fail on error
func trash(path: String) -> Bool {
let binaryPath = "/usr/local/bin/trash"
let process = Process()
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
process.arguments = [path]
if #available(OSX 10.13, *) {
process.executableURL = URL(fileURLWithPath: binaryPath)
do {
try process.run()
} catch {
printError("Unable to launch trash command")
return false
}
} else {
process.launchPath = binaryPath
process.launch()
catch {
return .failure(.uninstallFailed)
}
process.waitUntilExit()
if process.terminationStatus == 0 {
return true
} else {
let reason = process.terminationReason
let output = stderr.fileHandleForReading.readDataToEndOfFile()
printError("Uninstall failed: \(reason)\n\(String(data: output, encoding: String.Encoding.utf8)!)")
return false
}
return .success(())
}
}

View file

@ -23,4 +23,56 @@ public class MasAppLibrary: AppLibrary {
let appId = NSNumber(value: appId)
return softwareMap.allProducts()?.first { $0.itemIdentifier == appId }
}
/// Uninstalls an app.
///
/// - Parameter app: App to be removed.
/// - Throws: Error if there is a problem.
public func uninstallApp(app: SoftwareProduct) throws {
let status = trash(path: app.bundlePath)
if !status {
throw MASError.uninstallFailed
}
}
/// Runs the trash command in another process. Relies on the "trash" command
/// from Homebrew. Trash requires el_capitan or higher for core bottles:
/// https://github.com/Homebrew/homebrew-core/blob/master/Formula/trash.rb
///
/// - Parameter path: Absolute path to the application bundle to uninstall.
/// - Returns: true on success; fail on error
func trash(path: String) -> Bool {
let binaryPath = "/usr/local/bin/trash"
let process = Process()
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
process.arguments = [path]
if #available(OSX 10.13, *) {
process.executableURL = URL(fileURLWithPath: binaryPath)
do {
try process.run()
} catch {
printError("Unable to launch trash command")
return false
}
} else {
process.launchPath = binaryPath
process.launch()
}
process.waitUntilExit()
if process.terminationStatus == 0 {
return true
} else {
let reason = process.terminationReason
let output = stderr.fileHandleForReading.readDataToEndOfFile()
printError("Uninstall failed: \(reason)\n\(String(data: output, encoding: .utf8)!)")
return false
}
}
}

View file

@ -14,17 +14,61 @@ import Nimble
class UninstallCommandSpec: QuickSpec {
override func spec() {
describe("uninstall command") {
it("can't remove missing app") {
let mockLibrary = MockAppLibrary()
let uninstall = UninstallCommand(appLibrary: mockLibrary)
let appId = 12345
let appId = 12345
let app = MockSoftwareProduct(
appName: "Some App",
bundlePath: "/tmp/Some.app",
itemIdentifier: NSNumber(value: appId)
)
let mockLibrary = MockAppLibrary()
let uninstall = UninstallCommand(appLibrary: mockLibrary)
context("dry run") {
let options = UninstallCommand.Options(appId: appId, dryRun: true)
let result = uninstall.run(options)
print(result)
expect(result).to(beFailure { error in
expect(error) == .notInstalled
})
it("can't remove a missing app") {
mockLibrary.apps = []
let result = uninstall.run(options)
expect(result).to(beFailure { error in
expect(error) == .notInstalled
})
}
it("finds an app") {
mockLibrary.apps.append(app)
let result = uninstall.run(options)
expect(result).to(beSuccess())
}
}
context("wet run") {
let options = UninstallCommand.Options(appId: appId, dryRun: false)
it("can't remove a missing app") {
mockLibrary.apps = []
let result = uninstall.run(options)
expect(result).to(beFailure { error in
expect(error) == .notInstalled
})
}
it("removes an app") {
mockLibrary.apps.append(app)
let result = uninstall.run(options)
expect(result).to(beSuccess())
}
it("fails if there is a problem with the trash command") {
mockLibrary.apps = []
var brokenUninstall = app // make mutable copy
brokenUninstall.bundlePath = "/dev/null"
mockLibrary.apps.append(brokenUninstall)
let result = uninstall.run(options)
expect(result).to(beFailure { error in
expect(error) == .uninstallFailed
})
}
}
}
}

View file

@ -9,7 +9,22 @@
@testable import MasKit
class MockAppLibrary: AppLibrary {
var apps = [SoftwareProduct]()
func installedApp(appId: UInt64) -> SoftwareProduct? {
return nil
return apps.first { $0.itemIdentifier == NSNumber(value: appId) }
}
func uninstallApp(app: SoftwareProduct) throws {
if !apps.contains(where: { (product) -> Bool in
return app.itemIdentifier == product.itemIdentifier
}) { throw MASError.notInstalled }
// Special case for testing where we pretend the trash command failed
if app.bundlePath == "/dev/null" {
throw MASError.uninstallFailed
}
// Success is the default, watch out for false positives!
}
}

View file

@ -0,0 +1,15 @@
//
// MockSoftwareProduct.swift
// MasKitTests
//
// Created by Ben Chatelain on 12/27/18.
// Copyright © 2018 mas-cli. All rights reserved.
//
@testable import MasKit
struct MockSoftwareProduct: SoftwareProduct {
var appName: String
var bundlePath: String
var itemIdentifier: NSNumber
}

View file

@ -30,6 +30,7 @@
B594B12E21D5850700F3AC59 /* MockAppLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12D21D5850700F3AC59 /* MockAppLibrary.swift */; };
B594B13021D5855D00F3AC59 /* MasAppLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B12F21D5855D00F3AC59 /* MasAppLibrary.swift */; };
B594B13221D5876200F3AC59 /* ResultPredicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B13121D5876200F3AC59 /* ResultPredicates.swift */; };
B594B13421D5897100F3AC59 /* MockSoftwareProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = B594B13321D5897100F3AC59 /* MockSoftwareProduct.swift */; };
ED031A7C1B5127C00097692E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED031A7B1B5127C00097692E /* main.swift */; };
F83213892173D3E1008BA8A0 /* CKAccountStore.h in Headers */ = {isa = PBXBuildFile; fileRef = F8FB719B20F2EC4500F56FDC /* CKAccountStore.h */; };
F832138A2173D3E1008BA8A0 /* CKDownloadQueue.h in Headers */ = {isa = PBXBuildFile; fileRef = F8FB719C20F2EC4500F56FDC /* CKDownloadQueue.h */; };
@ -174,6 +175,7 @@
B594B12D21D5850700F3AC59 /* MockAppLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppLibrary.swift; sourceTree = "<group>"; };
B594B12F21D5855D00F3AC59 /* MasAppLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasAppLibrary.swift; sourceTree = "<group>"; };
B594B13121D5876200F3AC59 /* ResultPredicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultPredicates.swift; sourceTree = "<group>"; };
B594B13321D5897100F3AC59 /* MockSoftwareProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftwareProduct.swift; sourceTree = "<group>"; };
ED031A781B5127C00097692E /* mas */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = mas; sourceTree = BUILT_PRODUCTS_DIR; };
ED031A7B1B5127C00097692E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
ED0F237E1B87522400AE40CD /* Install.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Install.swift; sourceTree = "<group>"; };
@ -291,6 +293,7 @@
isa = PBXGroup;
children = (
B594B12D21D5850700F3AC59 /* MockAppLibrary.swift */,
B594B13321D5897100F3AC59 /* MockSoftwareProduct.swift */,
B5793E2A219BE0CD00135B39 /* MockURLSession.swift */,
);
path = Mocks;
@ -667,6 +670,7 @@
files = (
B5793E2B219BE0CD00135B39 /* MockURLSession.swift in Sources */,
B555292B219A1CB200ACB4CA /* MASErrorTestCase.swift in Sources */,
B594B13421D5897100F3AC59 /* MockSoftwareProduct.swift in Sources */,
B594B12221D5416100F3AC59 /* ListCommandSpec.swift in Sources */,
B594B12521D580BB00F3AC59 /* UninstallCommandSpec.swift in Sources */,
B555292D219A1FE700ACB4CA /* SearchSpec.swift in Sources */,