320 lines
13 KiB
Swift
320 lines
13 KiB
Swift
import XCTest
|
|
import MacToolsPluginKit
|
|
@testable import MacTools
|
|
@testable import HomebrewPlugin
|
|
|
|
@MainActor
|
|
final class HomebrewPluginTests: XCTestCase {
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath")
|
|
}
|
|
|
|
override func tearDown() {
|
|
UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath")
|
|
super.tearDown()
|
|
}
|
|
|
|
// Fake runner for testing
|
|
@MainActor
|
|
final class FakeHomebrewCommandRunner: HomebrewCommandRunning {
|
|
var stubbedStatus: Int32 = 0
|
|
var stubbedOutputs: [String: String] = [:]
|
|
var runCalls: [[String]] = []
|
|
var isCancelled = false
|
|
|
|
func run(
|
|
executable: String,
|
|
arguments: [String],
|
|
onOutput: @escaping @MainActor (String) -> Void,
|
|
onError: @escaping @MainActor (String) -> Void
|
|
) async throws -> Int32 {
|
|
runCalls.append(arguments)
|
|
|
|
// Determine stubbed output based on arguments
|
|
var matchKey = ""
|
|
if arguments.contains("tap") {
|
|
matchKey = "tap"
|
|
} else if arguments.contains("list") && arguments.contains("--formula") {
|
|
matchKey = "list-formula"
|
|
} else if arguments.contains("list") && arguments.contains("--cask") {
|
|
matchKey = "list-cask"
|
|
} else if arguments.contains("outdated") {
|
|
matchKey = "outdated"
|
|
} else if arguments.contains("info") && arguments.contains("--installed") {
|
|
matchKey = "info-installed"
|
|
} else if arguments.contains("search") && arguments.contains("--formula") {
|
|
matchKey = "search-formula"
|
|
} else if arguments.contains("search") && arguments.contains("--cask") {
|
|
matchKey = "search-cask"
|
|
}
|
|
|
|
if let output = stubbedOutputs[matchKey] {
|
|
onOutput(output)
|
|
}
|
|
|
|
return stubbedStatus
|
|
}
|
|
|
|
func cancel() async {
|
|
isCancelled = true
|
|
}
|
|
}
|
|
|
|
func testMetadataIdentifiesHomebrewPlugin() {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
let localization = PluginLocalization(bundle: .main)
|
|
let plugin = HomebrewPlugin(controller: controller, localization: localization)
|
|
|
|
XCTAssertEqual(plugin.metadata.id, "homebrew")
|
|
XCTAssertEqual(plugin.metadata.title, "Homebrew")
|
|
}
|
|
|
|
func testPanelUsesManageButton() {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
let localization = PluginLocalization(bundle: .main)
|
|
let plugin = HomebrewPlugin(controller: controller, localization: localization)
|
|
|
|
XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button)
|
|
XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .dismissBeforeHandling)
|
|
XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "管理")
|
|
XCTAssertNil(plugin.primaryPanelState.detail)
|
|
}
|
|
|
|
func testManageButtonRequestsConfigurationPresentation() {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
let localization = PluginLocalization(bundle: .main)
|
|
let plugin = HomebrewPlugin(controller: controller, localization: localization)
|
|
var requestCount = 0
|
|
plugin.requestConfigurationPresentation = {
|
|
requestCount += 1
|
|
}
|
|
|
|
plugin.handleAction(.invokeAction(controlID: HomebrewPlugin.ControlID.manage))
|
|
|
|
XCTAssertEqual(requestCount, 1)
|
|
}
|
|
|
|
func testScanPopulatesPackagesAndTaps() async throws {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
runner.stubbedOutputs = [
|
|
"tap": "homebrew/core\nhomebrew/cask\n",
|
|
"list-formula": "git 2.51.0\nripgrep 15.1.0\n",
|
|
"list-cask": "iterm2 3.5.0\n",
|
|
"outdated": "{\"formulae\":[{\"name\":\"git\",\"installed_versions\":[\"2.51.0\"],\"current_version\":\"2.55.0\",\"pinned\":false}],\"casks\":[]}",
|
|
"info-installed": "{\"formulae\":[{\"name\":\"git\",\"desc\":\"Distributed revision control system\",\"homepage\":\"https://git-scm.com/\",\"dependencies\":[\"pcre2\",\"openssl\"]}],\"casks\":[]}"
|
|
]
|
|
|
|
let controller = HomebrewController(runner: runner)
|
|
controller.isBrewAvailable = true // override for testing
|
|
controller.brewPath = "/opt/homebrew/bin/brew"
|
|
|
|
// Trigger scan
|
|
controller.scanAll()
|
|
|
|
// Wait for async operations to settle in controller (robust wait loop)
|
|
let deadline = Date().addingTimeInterval(5.0)
|
|
while controller.isBusy && Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
XCTAssertEqual(controller.taps.count, 2)
|
|
XCTAssertEqual(controller.taps.map(\.name), ["homebrew/core", "homebrew/cask"])
|
|
|
|
XCTAssertEqual(controller.installedPackages.count, 3)
|
|
let gitPkg = try XCTUnwrap(controller.installedPackages.first { $0.name == "git" })
|
|
XCTAssertEqual(gitPkg.version, "2.51.0")
|
|
XCTAssertEqual(gitPkg.latestVersion, "2.55.0")
|
|
XCTAssertTrue(gitPkg.isOutdated)
|
|
XCTAssertEqual(gitPkg.desc, "Distributed revision control system")
|
|
XCTAssertEqual(gitPkg.homepage, "https://git-scm.com/")
|
|
XCTAssertEqual(gitPkg.dependencies, ["pcre2", "openssl"])
|
|
|
|
// Verify reverse dependency logic
|
|
let ripPkg = try XCTUnwrap(controller.installedPackages.first { $0.name == "ripgrep" })
|
|
XCTAssertFalse(ripPkg.isOutdated)
|
|
XCTAssertEqual(ripPkg.requiredBy(in: controller.installedPackages), [])
|
|
}
|
|
|
|
func testSearchSkipsDuplicatePopulatedQuery() async throws {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
runner.stubbedOutputs = [
|
|
"search-formula": "wget\n",
|
|
"search-cask": "warp\n"
|
|
]
|
|
|
|
let controller = HomebrewController(runner: runner)
|
|
controller.isBrewAvailable = true
|
|
controller.brewPath = "/opt/homebrew/bin/brew"
|
|
|
|
controller.search(query: "w")
|
|
let deadline = Date().addingTimeInterval(5.0)
|
|
while controller.isSearching && Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
XCTAssertEqual(controller.searchResults.map(\.name), ["warp", "wget"])
|
|
XCTAssertEqual(runner.runCalls.count, 2)
|
|
|
|
controller.search(query: " w ")
|
|
|
|
XCTAssertEqual(controller.searchResults.map(\.name), ["warp", "wget"])
|
|
XCTAssertEqual(runner.runCalls.count, 2)
|
|
}
|
|
|
|
func testCustomPathPersistence() {
|
|
UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath")
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
let binDir = tempDir.appendingPathComponent("bin")
|
|
let tempBrewFile = binDir.appendingPathComponent("brew")
|
|
try? FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
|
|
try? "#!/bin/sh\n# HOMEBREW test shim\n".write(to: tempBrewFile, atomically: true, encoding: .utf8)
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempBrewFile.path)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath")
|
|
}
|
|
|
|
// Test updating path
|
|
controller.updateCustomPath(tempBrewFile.path)
|
|
XCTAssertTrue(controller.isBrewAvailable)
|
|
XCTAssertEqual(controller.brewPath, tempBrewFile.path)
|
|
XCTAssertEqual(UserDefaults.standard.string(forKey: "mactools.homebrew.customPath"), tempBrewFile.path)
|
|
|
|
// Test empty path resets standard path discovery
|
|
controller.updateCustomPath("")
|
|
XCTAssertEqual(UserDefaults.standard.string(forKey: "mactools.homebrew.customPath"), nil)
|
|
}
|
|
|
|
func testUnknownPanelActionIsIgnored() {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
controller.isBrewAvailable = true
|
|
controller.brewPath = "/opt/homebrew/bin/brew"
|
|
|
|
let localization = PluginLocalization(bundle: .main)
|
|
let plugin = HomebrewPlugin(controller: controller, localization: localization)
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "legacy-scan"))
|
|
|
|
XCTAssertFalse(controller.isBusy)
|
|
XCTAssertTrue(runner.runCalls.isEmpty)
|
|
}
|
|
|
|
func testCaskPackageActionsUseBrewSubcommandBeforeCaskFlag() async throws {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
runner.stubbedStatus = 1
|
|
|
|
let controller = HomebrewController(runner: runner)
|
|
controller.isBrewAvailable = true
|
|
controller.brewPath = "/opt/homebrew/bin/brew"
|
|
|
|
let package = BrewPackage(
|
|
name: "iterm2",
|
|
version: "3.5.0",
|
|
latestVersion: "3.5.0",
|
|
isCask: true,
|
|
desc: "",
|
|
homepage: "",
|
|
isOutdated: false,
|
|
isPinned: false
|
|
)
|
|
|
|
controller.install(package: package)
|
|
var deadline = Date().addingTimeInterval(5.0)
|
|
while controller.isBusy && Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
XCTAssertEqual(runner.runCalls.last, ["install", "--cask", "iterm2"])
|
|
|
|
runner.runCalls.removeAll()
|
|
controller.uninstall(package: package)
|
|
deadline = Date().addingTimeInterval(5.0)
|
|
while controller.isBusy && Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
XCTAssertEqual(runner.runCalls.last, ["uninstall", "--cask", "iterm2"])
|
|
|
|
runner.runCalls.removeAll()
|
|
controller.upgrade(package: package)
|
|
deadline = Date().addingTimeInterval(5.0)
|
|
while controller.isBusy && Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
XCTAssertEqual(runner.runCalls.last, ["upgrade", "--cask", "iterm2"])
|
|
}
|
|
|
|
func testPanelStateBusyAndNotAvailable() {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
let localization = PluginLocalization(bundle: .main)
|
|
let plugin = HomebrewPlugin(controller: controller, localization: localization)
|
|
|
|
// Case 1: Not installed
|
|
controller.isBrewAvailable = false
|
|
var state = plugin.primaryPanelState
|
|
XCTAssertNotNil(state.errorMessage)
|
|
|
|
// Case 2: Available and Busy
|
|
controller.isBrewAvailable = true
|
|
controller.isBusy = true
|
|
controller.currentOperationName = "Scanning..."
|
|
state = plugin.primaryPanelState
|
|
XCTAssertNil(state.errorMessage)
|
|
XCTAssertTrue(state.isOn)
|
|
XCTAssertEqual(state.subtitle, "Scanning...")
|
|
}
|
|
|
|
func testScanAllFailurePath() async throws {
|
|
let runner = FakeHomebrewCommandRunner()
|
|
runner.stubbedStatus = 1 // non-zero status indicating failure
|
|
|
|
let controller = HomebrewController(runner: runner)
|
|
controller.isBrewAvailable = true
|
|
controller.brewPath = "/opt/homebrew/bin/brew"
|
|
|
|
controller.scanAll()
|
|
|
|
let deadline = Date().addingTimeInterval(5.0)
|
|
while controller.isBusy && Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
// Verify logs contain system error entries
|
|
let hasErrorLog = controller.logs.contains { $0.isError }
|
|
XCTAssertTrue(hasErrorLog)
|
|
}
|
|
|
|
func testInvalidCustomPathIsRejected() {
|
|
UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath")
|
|
let runner = FakeHomebrewCommandRunner()
|
|
let controller = HomebrewController(runner: runner)
|
|
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString)
|
|
let binDir = tempDir.appendingPathComponent("bin")
|
|
let invalidBrewFile = binDir.appendingPathComponent("brew")
|
|
try? FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
|
|
try? "#!/bin/sh\n# not homebrew\n".write(to: invalidBrewFile, atomically: true, encoding: .utf8)
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: invalidBrewFile.path)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath")
|
|
}
|
|
|
|
controller.updateCustomPath(invalidBrewFile.path)
|
|
|
|
XCTAssertNil(UserDefaults.standard.string(forKey: "mactools.homebrew.customPath"))
|
|
XCTAssertNotEqual(controller.brewPath, invalidBrewFile.path)
|
|
XCTAssertTrue(controller.logs.contains { $0.isError })
|
|
}
|
|
}
|