Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

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 })
}
}